{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "ascii-image",
  "title": "ASCII Image",
  "description": "A photograph redrawn as characters, painted once and then left alone.",
  "registryDependencies": [
    "utils",
    "https://ui.tinkererslabs.com/r/render-surface.json"
  ],
  "files": [
    {
      "path": "registry/default/ascii-image/ascii-image.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport {\n  RenderSurface,\n  useThemeColors,\n  type SurfaceColor,\n} from \"@/registry/default/render-surface/render-surface\"\nimport { cn } from \"@/lib/utils\"\n\nexport type AsciiImageProps = React.HTMLAttributes<HTMLDivElement> & {\n  src: string\n  alt: string\n  /** Width of one character cell in pixels. Smaller is more detailed. */\n  cell?: number\n  /** Darkest character first. */\n  ramp?: string\n  color?: string\n  background?: string\n  contrast?: number\n}\n\nconst DEFAULT_RAMP = \"@%#*+=-:. \"\n\ntype Scene = {\n  grid: HTMLCanvasElement\n  sampler: HTMLCanvasElement\n  drawn: string | null\n}\n\nfunction rgb([r, g, b]: SurfaceColor) {\n  return `rgb(${Math.round(r * 255)}, ${Math.round(g * 255)}, ${Math.round(b * 255)})`\n}\n\n/**\n * Redraws a photograph as characters. The grid is worked out once and kept,\n * and each frame is a single copy of it, so nothing here recomputes thousands\n * of letters sixty times a second.\n */\nexport function AsciiImage({\n  src,\n  alt,\n  cell = 8,\n  ramp = DEFAULT_RAMP,\n  color = \"--foreground\",\n  background = \"--background\",\n  contrast = 1.2,\n  className,\n  children,\n  ...rootProps\n}: AsciiImageProps) {\n  const rootRef = React.useRef<HTMLDivElement>(null)\n  const picture = React.useRef<HTMLImageElement | null>(null)\n  const [ready, setReady] = React.useState<string | null>(null)\n\n  // Loading lives here rather than in setup, because setup runs again for\n  // reasons that have nothing to do with the picture.\n  React.useEffect(() => {\n    const image = new Image()\n    let cancelled = false\n\n    image.crossOrigin = \"anonymous\"\n    image.decoding = \"async\"\n    image.onload = () => {\n      if (cancelled) return\n      picture.current = image\n      setReady(src)\n    }\n    image.src = src\n\n    return () => {\n      cancelled = true\n    }\n  }, [src])\n\n  const tokens = React.useMemo(\n    () => [color, background].filter((entry) => entry.startsWith(\"--\")),\n    [background, color]\n  )\n\n  const resolved = useThemeColors(rootRef, tokens)\n  const ink = color.startsWith(\"--\") ? resolved[color] : undefined\n  const paper = background.startsWith(\"--\") ? resolved[background] : undefined\n  const themed = ink !== undefined && paper !== undefined\n\n  const recipe = `${ready}|${cell}|${ramp}|${contrast}|${ink?.join()}|${paper?.join()}`\n\n  const setup = React.useCallback(\n    (): Scene => ({\n      grid: document.createElement(\"canvas\"),\n      sampler: document.createElement(\"canvas\"),\n      drawn: null,\n    }),\n    []\n  )\n\n  const draw = React.useCallback(\n    ({\n      canvas,\n      context,\n      size,\n      state,\n    }: {\n      canvas: HTMLCanvasElement\n      context: CanvasRenderingContext2D\n      size: { width: number; height: number; dpr: number }\n      state: Scene\n    }) => {\n      if (!ink || !paper) return\n\n      const { width, height, dpr } = size\n      const wanted = `${recipe}|${Math.round(width)}x${Math.round(height)}|${dpr}`\n\n      if (state.drawn !== wanted) {\n        const image = picture.current\n        state.grid.width = canvas.width\n        state.grid.height = canvas.height\n\n        const grid = state.grid.getContext(\"2d\")\n        if (!grid) return\n\n        grid.setTransform(dpr, 0, 0, dpr, 0, 0)\n        grid.fillStyle = rgb(paper)\n        grid.fillRect(0, 0, width, height)\n\n        if (image) {\n          const cellWidth = Math.max(cell, 2)\n          const cellHeight = cellWidth * 1.6\n          const columns = Math.max(1, Math.floor(width / cellWidth))\n          const rows = Math.max(1, Math.floor(height / cellHeight))\n\n          state.sampler.width = columns\n          state.sampler.height = rows\n\n          const sampling = state.sampler.getContext(\"2d\", {\n            willReadFrequently: true,\n          })\n\n          if (!sampling) return\n\n          // Cover, so the picture keeps its shape in whatever box it was given.\n          const boxAspect = columns / rows\n          const imageAspect =\n            image.naturalWidth / Math.max(image.naturalHeight, 1)\n          const scaled =\n            boxAspect > imageAspect\n              ? { w: columns, h: columns / imageAspect }\n              : { w: rows * imageAspect, h: rows }\n\n          sampling.drawImage(\n            image,\n            (columns - scaled.w) / 2,\n            (rows - scaled.h) / 2,\n            scaled.w,\n            scaled.h\n          )\n\n          const data = sampling.getImageData(0, 0, columns, rows).data\n\n          // A canvas font string cannot hold a custom property, so the stack\n          // is read off the canvas, which carries the monospace class.\n          const family =\n            getComputedStyle(canvas).fontFamily || \"ui-monospace, monospace\"\n\n          grid.fillStyle = rgb(ink)\n          grid.font = `${cellHeight * 0.92}px ${family}`\n          grid.textBaseline = \"top\"\n\n          for (let row = 0; row < rows; row += 1) {\n            for (let column = 0; column < columns; column += 1) {\n              const index = (row * columns + column) * 4\n              const luma =\n                ((data[index] ?? 0) * 0.2126 +\n                  (data[index + 1] ?? 0) * 0.7152 +\n                  (data[index + 2] ?? 0) * 0.0722) /\n                255\n\n              const adjusted = Math.min(\n                1,\n                Math.max(0, (luma - 0.5) * contrast + 0.5)\n              )\n\n              const character =\n                ramp[\n                  Math.min(ramp.length - 1, Math.floor(adjusted * ramp.length))\n                ]\n\n              if (character && character !== \" \") {\n                grid.fillText(character, column * cellWidth, row * cellHeight)\n              }\n            }\n          }\n\n          state.drawn = wanted\n        }\n      }\n\n      context.setTransform(1, 0, 0, 1, 0, 0)\n      context.clearRect(0, 0, canvas.width, canvas.height)\n      context.drawImage(state.grid, 0, 0)\n    },\n    [cell, contrast, ink, paper, ramp, recipe]\n  )\n\n  return (\n    <div\n      ref={rootRef}\n      data-slot=\"ascii-image\"\n      className={cn(\n        \"bg-background relative isolate overflow-hidden rounded-[var(--radius)]\",\n        className\n      )}\n      {...rootProps}\n    >\n      {themed && (\n        <RenderSurface<Scene, \"2d\">\n          setup={setup}\n          draw={draw}\n          rebuildOnResize={false}\n          revision={recipe}\n          label={alt}\n          className=\"absolute inset-0\"\n          canvasClassName=\"font-mono\"\n        />\n      )}\n      {children !== undefined && <div className=\"relative\">{children}</div>}\n    </div>\n  )\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "scene",
    "media",
    "ascii",
    "canvas"
  ],
  "type": "registry:ui"
}