{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "ascii-dissolve",
  "title": "ASCII Dissolve",
  "description": "Dissolve one scene into the next through a drifting field of ASCII glyphs.",
  "dependencies": ["remotion", "@remotion/transitions"],
  "files": [
    {
      "path": "registry/remocn/ascii-dissolve/index.tsx",
      "content": "\"use client\";\n\nimport type {\n  TransitionPresentation,\n  TransitionPresentationComponentProps,\n} from \"@remotion/transitions\";\nimport React from \"react\";\nimport {\n  AbsoluteFill,\n  continueRender,\n  delayRender,\n  Easing,\n  interpolate,\n  random,\n  useVideoConfig,\n} from \"remotion\";\n\nconst clampOpts = {\n  extrapolateLeft: \"clamp\" as const,\n  extrapolateRight: \"clamp\" as const,\n};\n\n// ---------------------------------------------------------------------------\n// ascii-dissolve — the frame becomes plain text. The outgoing scene fades as\n// a fullscreen field of monospace glyphs rises over it: a deterministic\n// noise field drives each cell through a density ramp (\" .:-=+*#%@\"), so the\n// cover reads as living ASCII. It holds fully opaque long enough to be READ\n// as text, then the cells drop back down the ramp to space while the\n// incoming scene resolves beneath.\n//\n// Text mode (enterText): the outgoing scene blurs out under the rising\n// field; the incoming headline is never previewed as ascii — the field\n// dissolves cell by cell (per-cell staggered, never a flat plane) while\n// the real text fades in over it. `enterStyle` picks what happens AROUND\n// the arriving text:\n//   \"fade\"      — plain: staggered dissolve everywhere, text fades in.\n//   \"clearing\"  — the dissolve order is biased by distance to the text:\n//                 cells near the headline vanish first, the field opens a\n//                 granular cavity around it.\n//   \"halo\"      — a smooth radial attenuation grows around the text: near\n//                 cells slide down the ramp into thin glyphs, a soft dark\n//                 well forms behind the headline.\n//   \"wave\"      — one density impulse rides outward from the text: cells\n//                 on the wavefront jump up the ramp for a beat, then the\n//                 front leaves cleared field behind it.\n//   \"focus\"     — the field defocuses: it blurs and dims while the sharp\n//                 text resolves — a depth-of-field handoff.\n//   \"lime-echo\" — on landing, a sparse ring of accent-colored glyphs\n//                 flashes once around the headline and settles.\n//\n// Fully deterministic: per-cell jitter comes from remotion's seeded random,\n// the flow from fixed sinusoids over progress, the letterform masks from a\n// one-time offscreen-canvas sample of the loaded fonts.\n// ---------------------------------------------------------------------------\n\nexport type AsciiTextSpec = {\n  /** The incoming headline (used for timing and the zone styles). */\n  text: string;\n  /** Must match the scene's rendered text exactly. */\n  fontSize: number;\n  /** Concrete family list for canvas sampling (CSS vars won't resolve). */\n  fontFamily: string;\n  /** Default 400. */\n  fontWeight?: number;\n  /** Letter-spacing in em, if the scene's text carries one. Default 0. */\n  letterSpacingEm?: number;\n  /** Glyph color of the ASCII letterform. Default ink. */\n  color?: string;\n  /** Vertical offset from frame center, px. Default 0. */\n  offsetY?: number;\n};\n\nexport type AsciiEnterStyle =\n  | \"fade\"\n  | \"clearing\"\n  | \"halo\"\n  | \"wave\"\n  | \"focus\"\n  | \"lime-echo\";\n\nexport type AsciiDissolveProps = {\n  /** Row height of one glyph cell in px. Default 22 (use ~14 in text mode). */\n  cellSize?: number;\n  /** Glyph color. Default translucent ink. */\n  colorFront?: string;\n  /** Canvas behind the glyphs during the hold. Default near-black. */\n  colorBack?: string;\n  /** Optional second color for a sparse scattering of accent cells. */\n  accentColor?: string;\n  /** Share of cells rendered in the accent color, 0..1. Default 0.05. */\n  accentDensity?: number;\n  /** Density ramp, lightest → densest. Default \" .:-=+*#%@\". */\n  ramp?: string;\n  /** Monospace stack for the field. */\n  fontFamily?: string;\n  /** Text mode: the incoming headline (used for timing and as the zone). */\n  enterText?: AsciiTextSpec;\n  /** What happens around the arriving text. Default \"fade\". */\n  enterStyle?: AsciiEnterStyle;\n  /** Progress window over which the outgoing scene fades out. */\n  exitFade?: [number, number];\n  /** Progress window over which the incoming scene resolves. */\n  enterFade?: [number, number];\n};\n\n// Per-cell stagger: global window progress g plus jitter j → this cell's\n// local progress. spread controls how far cells drift apart.\nconst stagger = (g: number, j: number, spread = 0.55) =>\n  Math.max(0, Math.min(1, (g - j * spread) / (1 - spread)));\n\n// The field's mono advance, measured at the size the <pre> renders.\nconst measureAdvance = (fontFamily: string, fontSize: number): number => {\n  const fallback = fontSize * 0.6;\n  if (typeof document === \"undefined\") return fallback;\n  const ctx = document.createElement(\"canvas\").getContext(\"2d\");\n  if (!ctx) return fallback;\n  ctx.font = `400 ${fontSize}px ${fontFamily}`;\n  const w = ctx.measureText(\"0\").width;\n  return w > 0 ? w : fallback;\n};\n\n// Sample the headline's letterforms: draw the text once on an offscreen\n// canvas at 2× cell resolution and grade each cell by subsample coverage\n// (0..4). Edge cells get mid-ramp glyphs, core cells the densest — the\n// grading anti-aliases the letter contours, so strokes thinner than a cell\n// still read as continuous letters.\nconst buildTextMask = (\n  spec: AsciiTextSpec,\n  width: number,\n  height: number,\n  cols: number,\n  rows: number,\n  advance: number,\n  cellH: number,\n): Uint8Array | null => {\n  if (typeof document === \"undefined\") return null;\n  const canvas = document.createElement(\"canvas\");\n  canvas.width = cols * 2;\n  canvas.height = rows * 2;\n  const ctx = canvas.getContext(\"2d\", { willReadFrequently: true });\n  if (!ctx) return null;\n  ctx.setTransform(2 / advance, 0, 0, 2 / cellH, 0, 0);\n  ctx.font = `${spec.fontWeight ?? 400} ${spec.fontSize}px ${spec.fontFamily}`;\n  const spacing = (spec.letterSpacingEm ?? 0) * spec.fontSize;\n  if (spacing !== 0) {\n    (\n      ctx as CanvasRenderingContext2D & { letterSpacing?: string }\n    ).letterSpacing = `${spacing}px`;\n  }\n  ctx.textAlign = \"center\";\n  // Canvas \"middle\" baseline is computed from the em square and lands\n  // visibly ABOVE where a flex-centered DOM span puts the same text. Rebuild\n  // the DOM's math instead: a flex-centered span (line-height normal) puts\n  // its baseline at H/2 + (ascent - descent)/2, with ascent/descent taken\n  // from the font's own metrics.\n  ctx.textBaseline = \"alphabetic\";\n  ctx.fillStyle = \"#fff\";\n  const metrics = ctx.measureText(spec.text);\n  const ascent = metrics.fontBoundingBoxAscent ?? spec.fontSize * 0.8;\n  const descent = metrics.fontBoundingBoxDescent ?? spec.fontSize * 0.2;\n  const baselineY = height / 2 + (ascent - descent) / 2 + (spec.offsetY ?? 0);\n  ctx.fillText(spec.text, width / 2, baselineY);\n  const data = ctx.getImageData(0, 0, cols * 2, rows * 2).data;\n  const mask = new Uint8Array(cols * rows);\n  for (let y = 0; y < rows; y++) {\n    for (let x = 0; x < cols; x++) {\n      let hits = 0;\n      if (data[(y * 2 * cols * 2 + x * 2) * 4 + 3] > 100) hits++;\n      if (data[(y * 2 * cols * 2 + x * 2 + 1) * 4 + 3] > 100) hits++;\n      if (data[((y * 2 + 1) * cols * 2 + x * 2) * 4 + 3] > 100) hits++;\n      if (data[((y * 2 + 1) * cols * 2 + x * 2 + 1) * 4 + 3] > 100) hits++;\n      mask[y * cols + x] = hits;\n    }\n  }\n  return mask;\n};\n\nconst AsciiDissolvePresentation: React.FC<\n  TransitionPresentationComponentProps<AsciiDissolveProps>\n> = ({\n  children,\n  presentationProgress,\n  presentationDirection,\n  passedProps,\n}) => {\n  const {\n    cellSize = 22,\n    colorFront = \"rgba(242,242,242,0.6)\",\n    colorBack = \"#0d0d10\",\n    accentColor,\n    accentDensity = 0.05,\n    ramp = \" .:-=+*#%@\",\n    fontFamily = \"ui-monospace, SFMono-Regular, Menlo, monospace\",\n    enterText,\n    enterStyle = \"fade\",\n    exitFade,\n    enterFade,\n  } = passedProps;\n  const { width, height } = useVideoConfig();\n  const entering = presentationDirection === \"entering\";\n  const p = presentationProgress;\n  const textMode = enterText !== undefined;\n\n  // The outgoing scene blurs out under the rising field.\n  const exitWindow = exitFade ?? [0.14, 0.3];\n  // The real text fades in while the field dissolves cell by cell.\n  const enterWindow = enterFade ?? (textMode ? [0.6, 0.8] : [0.62, 0.8]);\n\n  // The masks and the mono advance must be measured with the REAL webfonts.\n  // The component can mount while they are still loading (a fallback font\n  // would bake wrong metrics into the memoized masks and shift the letterform\n  // off the scene's text) — so gate on document.fonts and hold the render.\n  const [fontsReady, setFontsReady] = React.useState(\n    () => typeof document === \"undefined\" || document.fonts.status === \"loaded\",\n  );\n  React.useEffect(() => {\n    if (fontsReady || typeof document === \"undefined\") return;\n    const handle = delayRender(\"ascii-dissolve: waiting for fonts\");\n    let alive = true;\n    document.fonts.ready.then(() => {\n      if (alive) setFontsReady(true);\n      continueRender(handle);\n    });\n    return () => {\n      alive = false;\n      continueRender(handle);\n    };\n  }, [fontsReady]);\n\n  const fontSize = cellSize * 0.86;\n  // biome-ignore lint/correctness/useExhaustiveDependencies: fontsReady is an intentional extra dep — remeasure the mono advance once the real webfonts load\n  const advance = React.useMemo(\n    () => measureAdvance(fontFamily, fontSize),\n    [fontFamily, fontSize, fontsReady],\n  );\n  const cols = Math.ceil(width / advance);\n  const rows = Math.ceil(height / cellSize) + 1;\n\n  // The zone styles need to know where the incoming text sits — a capsule\n  // around its center line, derived from the letterform mask's bounds. The\n  // mask itself is never displayed.\n  const zoneStyles: AsciiEnterStyle[] = [\n    \"clearing\",\n    \"halo\",\n    \"wave\",\n    \"lime-echo\",\n  ];\n  const needZone = enterText !== undefined && zoneStyles.includes(enterStyle);\n  const enterMask = React.useMemo(\n    () =>\n      enterText && needZone && fontsReady\n        ? buildTextMask(enterText, width, height, cols, rows, advance, cellSize)\n        : null,\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n    [\n      enterText,\n      needZone,\n      fontsReady,\n      width,\n      height,\n      cols,\n      rows,\n      advance,\n      cellSize,\n    ],\n  );\n  const enterZone = React.useMemo(() => {\n    if (enterMask === null) return null;\n    let minX = Infinity;\n    let maxX = -Infinity;\n    let minY = Infinity;\n    let maxY = -Infinity;\n    for (let y = 0; y < rows; y++) {\n      for (let x = 0; x < cols; x++) {\n        if (enterMask[y * cols + x] > 0) {\n          if (x < minX) minX = x;\n          if (x > maxX) maxX = x;\n          if (y < minY) minY = y;\n          if (y > maxY) maxY = y;\n        }\n      }\n    }\n    if (maxX < minX) return null;\n    return {\n      x0: minX * advance,\n      x1: (maxX + 1) * advance,\n      cy: ((minY + maxY + 1) / 2) * cellSize,\n      halfH: ((maxY - minY + 1) / 2) * cellSize,\n    };\n  }, [enterMask, cols, rows, advance, cellSize]);\n\n  if (!entering) {\n    const exitStyle: React.CSSProperties = {\n      opacity: interpolate(p, exitWindow, [1, 0], clampOpts),\n      filter: `blur(${interpolate(p, [exitWindow[0], exitWindow[1] + 0.06], [0, 8], clampOpts)}px)`,\n    };\n    return <AbsoluteFill style={exitStyle}>{children}</AbsoluteFill>;\n  }\n\n  // -------------------------------------------------------------------------\n  // Envelopes.\n  // -------------------------------------------------------------------------\n  // Generic mode keeps the original flat envelopes; text mode staggers the\n  // rise and the dissolve per cell instead, so nothing moves as one plane.\n  const coverage = textMode\n    ? 1\n    : interpolate(p, [0.06, 0.28, 0.6, 0.9], [0, 1, 1, 0], clampOpts);\n  const fieldOpacity = textMode\n    ? 1\n    : interpolate(p, [0.04, 0.2, 0.66, 0.94], [0, 1, 1, 0], clampOpts);\n  const panelOpacity = textMode\n    ? interpolate(p, [0.06, 0.24, 0.55, 0.75], [0, 1, 1, 0], clampOpts)\n    : fieldOpacity;\n\n  // Text-mode phase clocks. The outgoing scene blurs out as the field\n  // rises; the field boils, then dissolves cell by cell while the real\n  // text fades in.\n  const riseG = interpolate(p, [0.04, 0.3], [0, 1], clampOpts);\n  const dissolveG = interpolate(\n    p,\n    enterStyle === \"clearing\" ? [0.52, 0.85] : [0.5, 0.82],\n    [0, 1],\n    clampOpts,\n  );\n\n  // Style clocks for the arriving text's surroundings.\n  const haloG = interpolate(p, [0.52, 0.74], [0, 1], clampOpts);\n  const waveT = interpolate(p, [0.56, 0.94], [0, 1], {\n    ...clampOpts,\n    easing: Easing.out(Easing.quad),\n  });\n  const waveR = waveT * 860; //  the impulse front, px from the text capsule\n  const echoFlash = interpolate(p, [0.56, 0.64, 0.82], [0, 0.9, 0], clampOpts);\n  const focusBlur = interpolate(p, [0.55, 0.9], [0, 4], clampOpts);\n  const focusFade = interpolate(p, [0.62, 0.94], [1, 0], clampOpts);\n\n  const cx = cols / 2;\n  const cy = rows / 2;\n  const t = p * 5;\n  const rampLen = ramp.length;\n\n  const mainRows: string[] = [];\n  const accentRows: string[] = [];\n  const echoRows: string[] = [];\n  const fieldAlive = textMode ? p < 0.999 : fieldOpacity > 0.001;\n\n  if (fieldAlive) {\n    for (let y = 0; y < rows; y++) {\n      let mainRow = \"\";\n      let accentRow = \"\";\n      let echoRow = \"\";\n      for (let x = 0; x < cols; x++) {\n        // A slow sinusoid flow plus per-cell jitter — organic, but textual.\n        const base =\n          0.5 +\n          (Math.sin(x * 0.33 + t * 1.7) +\n            Math.sin(y * 0.51 - t * 1.2) +\n            Math.sin((x * 0.5 + y) * 0.24 + t * 0.9) +\n            Math.sin(Math.hypot(x - cx, (y - cy) * 1.8) * 0.42 - t * 2.1)) /\n            8;\n        const jitter = random(`ascii-${x}-${y}`);\n\n        // Distance to the incoming text's capsule, for the zone styles —\n        // and the plain circular distance from its center, for the wave.\n        let dist = Infinity;\n        let distC = Infinity;\n        let s = 0; // proximity, 1 at the text's center line → 0 far away\n        if (enterZone !== null) {\n          const px = (x + 0.5) * advance;\n          const py = (y + 0.5) * cellSize;\n          const nx = Math.min(Math.max(px, enterZone.x0), enterZone.x1);\n          dist = Math.hypot(px - nx, py - enterZone.cy);\n          distC = Math.hypot(\n            px - (enterZone.x0 + enterZone.x1) / 2,\n            py - enterZone.cy,\n          );\n          s = Math.max(0, 1 - dist / (enterZone.halfH + 64));\n        }\n\n        let cellCoverage: number;\n        if (!textMode) {\n          cellCoverage = coverage;\n        } else if (enterStyle === \"clearing\" && enterZone !== null) {\n          // Dissolve order biased by proximity: cells near the headline\n          // vanish first, a granular cavity opens outward.\n          const order = (1 - s) * 0.6 + random(`ascii-d-${x}-${y}`) * 0.4;\n          const gone = Math.max(\n            0,\n            Math.min(1, (dissolveG * 1.35 - order) / 0.3),\n          );\n          cellCoverage = stagger(riseG, jitter) * (1 - gone);\n        } else if (enterStyle === \"wave\" && enterZone !== null) {\n          // The impulse front — a CIRCLE from the text's center — clears\n          // the field behind it.\n          const passed = Math.max(0, Math.min(1, (waveR - distC) / 55));\n          cellCoverage = stagger(riseG, jitter) * (1 - passed);\n        } else {\n          cellCoverage =\n            stagger(riseG, jitter) *\n            (1 - stagger(dissolveG, random(`ascii-d-${x}-${y}`)));\n        }\n\n        let d = Math.max(\n          0,\n          Math.min(\n            0.999,\n            (base * 0.72 + jitter * 0.28) * cellCoverage * 1.2 - 0.08,\n          ),\n        );\n        if (enterStyle === \"halo\" && enterZone !== null) {\n          // A smooth radial attenuation: near cells slide down the ramp.\n          d *= 1 - haloG * s;\n        }\n        if (enterStyle === \"wave\" && enterZone !== null && waveT > 0) {\n          // Cells on the circular wavefront jump up the ramp for a beat.\n          const bump = Math.exp(-(((distC - waveR) / 55) ** 2));\n          d = Math.min(0.999, d + bump * 0.5);\n        }\n        const ch = ramp[Math.floor(d * rampLen)] ?? \" \";\n        const isAccent =\n          accentColor !== undefined &&\n          random(`ascii-a-${x}-${y}`) < accentDensity;\n        mainRow += isAccent ? \" \" : ch;\n        accentRow += isAccent ? ch : \" \";\n\n        // The lime echo: a sparse ring of accent glyphs around the headline.\n        if (\n          enterStyle === \"lime-echo\" &&\n          enterZone !== null &&\n          echoFlash > 0.01 &&\n          dist > 8 &&\n          dist < 84 &&\n          ch !== \" \" &&\n          random(`ascii-e-${x}-${y}`) < 0.5\n        ) {\n          echoRow += ch;\n        } else {\n          echoRow += \" \";\n        }\n      }\n      mainRows.push(mainRow);\n      accentRows.push(accentRow);\n      echoRows.push(echoRow);\n    }\n  }\n\n  // The blur must hit exactly 0 by p = 1: the entering presentation stays\n  // mounted at p = 1 for the whole scene, so any residue would soften the\n  // incoming scene permanently. In text mode the incoming scene resolves\n  // with NO transform at all — it must sit pixel-exact under the letterform.\n  const enterBlur = textMode\n    ? 0\n    : interpolate(\n        p,\n        [enterWindow[0], Math.min(1, enterWindow[1] + 0.05)],\n        [10, 0],\n        clampOpts,\n      );\n  const childStyle: React.CSSProperties = textMode\n    ? { opacity: interpolate(p, enterWindow, [0, 1], clampOpts) }\n    : {\n        opacity: interpolate(p, enterWindow, [0, 1], clampOpts),\n        transform: `scale(${interpolate(p, [enterWindow[0], 1], [1.04, 1], {\n          ...clampOpts,\n          easing: Easing.out(Easing.cubic),\n        })})`,\n        filter: enterBlur > 0.01 ? `blur(${enterBlur}px)` : undefined,\n      };\n\n  const preStyle: React.CSSProperties = {\n    margin: 0,\n    position: \"absolute\",\n    inset: 0,\n    overflow: \"hidden\",\n    whiteSpace: \"pre\",\n    fontFamily,\n    fontSize,\n    lineHeight: `${cellSize}px`,\n    color: colorFront,\n  };\n\n  const renderLayer = (\n    layerRows: string[],\n    color: string,\n    layerOpacity: number,\n  ) =>\n    layerOpacity > 0.001 ? (\n      <pre style={{ ...preStyle, color, opacity: layerOpacity }}>\n        {layerRows.join(\"\\n\")}\n      </pre>\n    ) : null;\n\n  return (\n    <AbsoluteFill>\n      <AbsoluteFill style={childStyle}>{children}</AbsoluteFill>\n      {fieldAlive ? (\n        <AbsoluteFill style={{ pointerEvents: \"none\" }}>\n          <AbsoluteFill\n            style={{ background: colorBack, opacity: panelOpacity }}\n          />\n          <AbsoluteFill\n            style={\n              enterStyle === \"focus\" && textMode\n                ? {\n                    opacity: focusFade,\n                    filter:\n                      focusBlur > 0.01 ? `blur(${focusBlur}px)` : undefined,\n                  }\n                : undefined\n            }\n          >\n            {renderLayer(mainRows, colorFront, fieldOpacity)}\n            {accentColor !== undefined\n              ? renderLayer(accentRows, accentColor, fieldOpacity)\n              : null}\n          </AbsoluteFill>\n          {enterStyle === \"lime-echo\"\n            ? renderLayer(echoRows, accentColor ?? \"#C3E88D\", echoFlash)\n            : null}\n        </AbsoluteFill>\n      ) : null}\n    </AbsoluteFill>\n  );\n};\n\nexport function asciiDissolve(\n  props: AsciiDissolveProps = {},\n): TransitionPresentation<AsciiDissolveProps> {\n  return {\n    component: AsciiDissolvePresentation,\n    props,\n  };\n}\n",
      "type": "registry:component",
      "target": "components/remocn/ascii-dissolve.tsx"
    }
  ],
  "type": "registry:component"
}
