{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "line-boil",
  "title": "Line Boil",
  "description": "A hand-drawn outline boils like a cel animation, redrawn every few frames with a varying pen weight, then fills with color while it keeps boiling.",
  "dependencies": ["remotion", "@remotion/paths"],
  "files": [
    {
      "path": "registry/remocn/line-boil/index.tsx",
      "content": "\"use client\";\n\nimport { getLength, getPointAtLength } from \"@remotion/paths\";\nimport { useMemo } from \"react\";\nimport {\n  AbsoluteFill,\n  Easing,\n  interpolate,\n  random,\n  useCurrentFrame,\n  useVideoConfig,\n} from \"remotion\";\n\nexport type LineBoilColors = {\n  line: string;\n  fill: string;\n  background: string;\n};\n\nexport type LineBoilProps = {\n  /** Closed outline as SVG path data, any coordinate space. Fitted to the frame. Defaults to a 5-point star. */\n  path?: string;\n  /** Frames each drawing holds before the next one (\"on threes\" by default). */\n  boilEvery?: number;\n  /** Distinct drawings the boil cycles through. */\n  drawings?: number;\n  /** Wobble strength; 1 is a gentle hand-drawn boil. */\n  amplitude?: number;\n  /** Base line weight in px at 720p. */\n  strokeWidth?: number;\n  /** Shape size as a fraction of the short frame side. */\n  size?: number;\n  colors?: Partial<LineBoilColors>;\n  /** Outline color. Overrides `colors.line` when set. */\n  line?: string;\n  /** Shape fill. Overrides `colors.fill` when set. */\n  fill?: string;\n  /** Background. Overrides `colors.background` when set. */\n  background?: string;\n  speed?: number;\n};\n\nconst starPath = (() => {\n  const points = Array.from({ length: 10 }, (_, i) => {\n    const r = i % 2 === 0 ? 100 : 40;\n    const a = (-90 + i * 36) * (Math.PI / 180);\n    return `${(100 + r * Math.cos(a)).toFixed(2)} ${(100 + r * Math.sin(a)).toFixed(2)}`;\n  });\n  return `M ${points.join(\" L \")} Z`;\n})();\n\nexport const lineBoilDefaults = {\n  path: starPath,\n  boilEvery: 3,\n  drawings: 3,\n  amplitude: 1,\n  strokeWidth: 9,\n  size: 0.66,\n  colors: {\n    line: \"#111111\",\n    fill: \"#E5322D\",\n    background: \"#FFFFFF\",\n  } as LineBoilColors,\n  speed: 1,\n};\n\nconst SAMPLES = 160;\nconst DRAW_END = 22;\nconst FILL_START = 28;\nconst FILL_END = 40;\nexport const lineBoilLength = FILL_END;\n\nconst clamp = {\n  extrapolateLeft: \"clamp\" as const,\n  extrapolateRight: \"clamp\" as const,\n};\n\ntype Point = { x: number; y: number };\n\n// Low-frequency integer harmonics keep the closed loop seamless while giving\n// the slow, rubbery wobble of a re-traced cel line.\nfunction boil(points: Point[], seed: string, amp: number): Point[] {\n  const harmonics = [2, 4, 9, 15].map((k, j) => ({\n    k,\n    weight: [1, 0.7, 0.45, 0.3][j],\n    px: random(`${seed}-px-${k}`) * Math.PI * 2,\n    py: random(`${seed}-py-${k}`) * Math.PI * 2,\n  }));\n  return points.map((p, i) => {\n    const s = i / points.length;\n    let dx = 0;\n    let dy = 0;\n    for (const h of harmonics) {\n      dx += h.weight * Math.sin(Math.PI * 2 * h.k * s + h.px);\n      dy += h.weight * Math.sin(Math.PI * 2 * h.k * s + h.py);\n    }\n    const nx = random(`${seed}-nx-${i}`) - 0.5;\n    const ny = random(`${seed}-ny-${i}`) - 0.5;\n    return {\n      x: p.x + amp * (dx * 0.6 + nx * 0.14),\n      y: p.y + amp * (dy * 0.6 + ny * 0.14),\n    };\n  });\n}\n\nexport function LineBoil({\n  path = lineBoilDefaults.path,\n  boilEvery = lineBoilDefaults.boilEvery,\n  drawings = lineBoilDefaults.drawings,\n  amplitude = lineBoilDefaults.amplitude,\n  strokeWidth = lineBoilDefaults.strokeWidth,\n  size = lineBoilDefaults.size,\n  colors,\n  line: lineColor,\n  fill: fillColor,\n  background: backgroundColor,\n  speed = lineBoilDefaults.speed,\n}: LineBoilProps) {\n  const frame = useCurrentFrame();\n  const { width, height, fps } = useVideoConfig();\n  const merged = { ...lineBoilDefaults.colors, ...colors };\n  const palette: LineBoilColors = {\n    line: lineColor ?? merged.line,\n    fill: fillColor ?? merged.fill,\n    background: backgroundColor ?? merged.background,\n  };\n  const t = (frame / fps) * 30 * speed;\n  const unit = Math.min(width, height) / 720;\n\n  const base = useMemo(() => {\n    const length = getLength(path);\n    const raw = Array.from(\n      { length: SAMPLES },\n      (_, i) =>\n        getPointAtLength(path, (length * i) / SAMPLES) ?? { x: 0, y: 0 },\n    );\n    const xs = raw.map((p) => p.x);\n    const ys = raw.map((p) => p.y);\n    const minX = Math.min(...xs);\n    const minY = Math.min(...ys);\n    const bw = Math.max(...xs) - minX;\n    const bh = Math.max(...ys) - minY;\n    const scale = (size * Math.min(width, height)) / Math.max(bw, bh, 1e-6);\n    const ox = width / 2 - (bw * scale) / 2;\n    const oy = height / 2 - (bh * scale) / 2;\n    return raw.map((p) => ({\n      x: ox + (p.x - minX) * scale,\n      y: oy + (p.y - minY) * scale,\n    }));\n  }, [path, size, width, height]);\n\n  const drawing =\n    Math.floor(t / Math.max(1, boilEvery)) % Math.max(1, drawings);\n  const amp = amplitude * 6.5 * unit;\n  const line = boil(base, `line-boil-${drawing}`, amp);\n  const fillShape = boil(base, `line-boil-fill-${drawing}`, amp * 1.4);\n\n  const draw = interpolate(t, [0, DRAW_END], [0, 1], {\n    ...clamp,\n    easing: Easing.inOut(Easing.cubic),\n  });\n  const visible = Math.round(draw * SAMPLES);\n  const fill = interpolate(t, [FILL_START, FILL_END], [0, 1], {\n    ...clamp,\n    easing: Easing.out(Easing.back(1.6)),\n  });\n\n  const cx = base.reduce((sum, p) => sum + p.x, 0) / base.length;\n  const cy = base.reduce((sum, p) => sum + p.y, 0) / base.length;\n  const pressurePhase = random(`line-boil-pressure-${drawing}`) * Math.PI * 2;\n\n  return (\n    <AbsoluteFill style={{ backgroundColor: palette.background }}>\n      <svg\n        width={width}\n        height={height}\n        viewBox={`0 0 ${width} ${height}`}\n        role=\"img\"\n        aria-label=\"Boiling hand-drawn outline\"\n      >\n        {fill > 0 ? (\n          <polygon\n            points={fillShape.map((p) => `${p.x},${p.y}`).join(\" \")}\n            fill={palette.fill}\n            transform={`translate(${cx} ${cy}) scale(${fill}) translate(${-cx} ${-cy})`}\n          />\n        ) : null}\n        <g stroke={palette.line} strokeLinecap=\"round\" fill=\"none\">\n          {line.slice(0, visible).map((p, i) => {\n            const next = line[(i + 1) % line.length];\n            const s = i / SAMPLES;\n            const pressure =\n              0.75 +\n              0.25 * Math.sin(Math.PI * 2 * 3 * s + pressurePhase) +\n              0.12 * Math.sin(Math.PI * 2 * 7 * s - pressurePhase);\n            // Taper the pen in and out while the line is still being drawn.\n            const taper =\n              draw < 1 ? Math.min(1, (visible - i) / 10, (i + 4) / 10) : 1;\n            return (\n              <line\n                key={i}\n                x1={p.x}\n                y1={p.y}\n                x2={next.x}\n                y2={next.y}\n                strokeWidth={strokeWidth * unit * pressure * taper}\n              />\n            );\n          })}\n        </g>\n      </svg>\n    </AbsoluteFill>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/remocn/line-boil.tsx"
    }
  ],
  "type": "registry:component"
}
