{"$schema":"https://ui.shadcn.com/schema/registry-item.json","author":"Eduardo Calvo <educlopez93@gmail.com>","css":{},"dependencies":["motion"],"description":"An AsciiRender component for SmoothUI.","devDependencies":[],"files":[{"content":"\"use client\";\n\nimport { cn } from \"@/lib/utils\";\nimport { useReducedMotion } from \"motion/react\";\nimport { useEffect, useRef, useState } from \"react\";\n\nexport type AsciiColorMode = \"mono\" | \"source\";\n\nexport interface AsciiRenderSource {\n  /** URL of the image or video to sample. Must allow cross-origin reads. */\n  src: string;\n  /** Kind of media behind `src`. */\n  type: \"image\" | \"video\";\n}\n\nexport interface AsciiRenderProps {\n  /** Description of the media. Exposed to assistive technology. */\n  alt: string;\n  /** Character ramp ordered dense to sparse. */\n  charset?: string;\n  /** Extra classes for the wrapper. */\n  className?: string;\n  /** `mono` paints one theme colour, `source` tints the glyphs with the frame. */\n  color?: AsciiColorMode;\n  /** Number of character columns sampled across the frame. */\n  columns?: number;\n  /** Glyph size in pixels. */\n  fontSize?: number;\n  /** Frames per second for video sources. */\n  fps?: number;\n  /** Flip the luminance-to-character mapping. */\n  invert?: boolean;\n  /** Hold the current frame without tearing down the renderer. */\n  paused?: boolean;\n  /** Media descriptor. */\n  source: AsciiRenderSource;\n}\n\ntype RenderStatus = \"fallback\" | \"loading\" | \"ready\";\n\nconst DEFAULT_CHARSET = \"@%#*+=-:. \";\nconst DEFAULT_COLUMNS = 80;\nconst DEFAULT_FONT_SIZE = 10;\nconst DEFAULT_FPS = 24;\nconst MIN_COLUMNS = 8;\nconst MAX_COLUMNS = 240;\nconst MIN_FPS = 1;\nconst MAX_FPS = 60;\nconst CHAR_ASPECT = 0.5;\nconst CHANNELS = 4;\nconst MAX_CHANNEL = 255;\nconst LUMA_R = 0.2126;\nconst LUMA_G = 0.7152;\nconst LUMA_B = 0.0722;\nconst MS_PER_SECOND = 1000;\nconst NEWLINE = \"\\n\";\nconst VISIBILITY_MARGIN = \"0px 0px 10% 0px\";\n\nconst clamp = (value: number, min: number, max: number) =>\n  Math.max(min, Math.min(max, value));\n\nconst buildAscii = (\n  data: Uint8ClampedArray,\n  columns: number,\n  rows: number,\n  charset: string,\n  invert: boolean\n) => {\n  const lastIndex = charset.length - 1;\n  const glyphs: string[] = [];\n  for (let y = 0; y < rows; y++) {\n    for (let x = 0; x < columns; x++) {\n      const offset = (y * columns + x) * CHANNELS;\n      const luminance =\n        (data[offset] * LUMA_R +\n          data[offset + 1] * LUMA_G +\n          data[offset + 2] * LUMA_B) /\n        MAX_CHANNEL;\n      const level = invert ? 1 - luminance : luminance;\n      glyphs.push(charset[clamp(Math.round(level * lastIndex), 0, lastIndex)]);\n    }\n    glyphs.push(NEWLINE);\n  }\n  return glyphs.join(\"\");\n};\n\nconst AsciiRender = ({\n  alt,\n  charset = DEFAULT_CHARSET,\n  className,\n  color = \"mono\",\n  columns = DEFAULT_COLUMNS,\n  fontSize = DEFAULT_FONT_SIZE,\n  fps = DEFAULT_FPS,\n  invert = false,\n  paused = false,\n  source,\n}: AsciiRenderProps) => {\n  const shouldReduceMotion = useReducedMotion();\n  const containerRef = useRef<HTMLDivElement>(null);\n  const preRef = useRef<HTMLPreElement>(null);\n  const imageRef = useRef<HTMLImageElement>(null);\n  const videoRef = useRef<HTMLVideoElement>(null);\n  const [status, setStatus] = useState<RenderStatus>(\"loading\");\n  const [isVisible, setIsVisible] = useState(true);\n\n  const { src: sourceSrc, type: sourceType } = source;\n  const safeCharset = charset.length > 1 ? charset : DEFAULT_CHARSET;\n  const safeColumns = clamp(Math.round(columns), MIN_COLUMNS, MAX_COLUMNS);\n  const safeFps = clamp(Math.round(fps), MIN_FPS, MAX_FPS);\n\n  useEffect(() => {\n    const container = containerRef.current;\n    if (!container || typeof IntersectionObserver === \"undefined\") {\n      return;\n    }\n    const observer = new IntersectionObserver(\n      (entries) => {\n        for (const entry of entries) {\n          setIsVisible(entry.isIntersecting);\n        }\n      },\n      { rootMargin: VISIBILITY_MARGIN }\n    );\n    observer.observe(container);\n    return () => {\n      observer.disconnect();\n    };\n  }, []);\n\n  useEffect(() => {\n    const pre = preRef.current;\n    const media = sourceType === \"video\" ? videoRef.current : imageRef.current;\n    if (!(pre && media && sourceSrc)) {\n      return;\n    }\n\n    const grid = document.createElement(\"canvas\");\n    const context = grid.getContext(\"2d\", { willReadFrequently: true });\n    if (!context) {\n      setStatus(\"fallback\");\n      return;\n    }\n\n    const video = sourceType === \"video\" ? (media as HTMLVideoElement) : null;\n    const image = sourceType === \"image\" ? (media as HTMLImageElement) : null;\n    let frame = 0;\n    let lastDrawnAt = 0;\n    let isCancelled = false;\n\n    const naturalSize = () => {\n      if (video) {\n        return { height: video.videoHeight, width: video.videoWidth };\n      }\n      return {\n        height: image?.naturalHeight ?? 0,\n        width: image?.naturalWidth ?? 0,\n      };\n    };\n\n    const drawFrame = () => {\n      const { height: mediaHeight, width: mediaWidth } = naturalSize();\n      if (mediaWidth === 0 || mediaHeight === 0) {\n        return false;\n      }\n      const rows = Math.max(\n        1,\n        Math.round((safeColumns * mediaHeight * CHAR_ASPECT) / mediaWidth)\n      );\n      grid.width = safeColumns;\n      grid.height = rows;\n      context.clearRect(0, 0, safeColumns, rows);\n      context.drawImage(media, 0, 0, safeColumns, rows);\n\n      const { data } = context.getImageData(0, 0, safeColumns, rows);\n      pre.textContent = buildAscii(\n        data,\n        safeColumns,\n        rows,\n        safeCharset,\n        invert\n      );\n      pre.style.backgroundImage =\n        color === \"source\" ? `url(${grid.toDataURL()})` : \"\";\n      return true;\n    };\n\n    const safeDraw = () => {\n      try {\n        return drawFrame();\n      } catch {\n        setStatus(\"fallback\");\n        return false;\n      }\n    };\n\n    const tick = (time: number) => {\n      if (isCancelled) {\n        return;\n      }\n      const interval = MS_PER_SECOND / safeFps;\n      if (time - lastDrawnAt >= interval) {\n        lastDrawnAt = time;\n        if (!safeDraw()) {\n          return;\n        }\n      }\n      frame = requestAnimationFrame(tick);\n    };\n\n    const start = () => {\n      if (isCancelled) {\n        return;\n      }\n      if (!safeDraw()) {\n        return;\n      }\n      setStatus(\"ready\");\n      const isStatic = !video || shouldReduceMotion || paused || !isVisible;\n      if (isStatic) {\n        video?.pause();\n        return;\n      }\n      const playback = video.play();\n      playback?.catch(() => {\n        setStatus(\"fallback\");\n      });\n      frame = requestAnimationFrame(tick);\n    };\n\n    const handleError = () => {\n      if (!isCancelled) {\n        setStatus(\"fallback\");\n      }\n    };\n\n    if (video) {\n      if (video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) {\n        start();\n      } else {\n        video.addEventListener(\"loadeddata\", start, { once: true });\n      }\n      video.addEventListener(\"error\", handleError);\n    } else if (image) {\n      if (image.complete) {\n        start();\n      } else {\n        image.addEventListener(\"load\", start, { once: true });\n      }\n      image.addEventListener(\"error\", handleError);\n    }\n\n    return () => {\n      isCancelled = true;\n      cancelAnimationFrame(frame);\n      video?.removeEventListener(\"loadeddata\", start);\n      video?.removeEventListener(\"error\", handleError);\n      video?.pause();\n      image?.removeEventListener(\"load\", start);\n      image?.removeEventListener(\"error\", handleError);\n      grid.width = 0;\n      grid.height = 0;\n    };\n  }, [\n    color,\n    invert,\n    isVisible,\n    paused,\n    safeCharset,\n    safeColumns,\n    safeFps,\n    shouldReduceMotion,\n    sourceSrc,\n    sourceType,\n  ]);\n\n  const isFallback = status === \"fallback\";\n  const mediaClassName = cn(\n    \"h-full w-full object-contain\",\n    isFallback ? \"relative\" : \"pointer-events-none absolute inset-0 opacity-0\"\n  );\n\n  return (\n    <div\n      className={cn(\n        \"relative isolate overflow-hidden rounded-xl bg-background\",\n        className\n      )}\n      ref={containerRef}\n    >\n      {sourceType === \"video\" ? (\n        <video\n          aria-hidden=\"true\"\n          className={mediaClassName}\n          crossOrigin=\"anonymous\"\n          loop\n          muted\n          playsInline\n          preload=\"auto\"\n          ref={videoRef}\n          src={sourceSrc}\n          tabIndex={-1}\n        />\n      ) : (\n        <img\n          alt=\"\"\n          aria-hidden=\"true\"\n          className={mediaClassName}\n          crossOrigin=\"anonymous\"\n          ref={imageRef}\n          src={sourceSrc}\n        />\n      )}\n\n      <pre\n        aria-hidden=\"true\"\n        className={cn(\n          \"m-0 select-none whitespace-pre font-mono\",\n          color === \"mono\" ? \"text-foreground\" : \"text-transparent\",\n          isFallback ? \"hidden\" : \"block\"\n        )}\n        ref={preRef}\n        style={{\n          backgroundClip: color === \"source\" ? \"text\" : undefined,\n          backgroundSize: \"100% 100%\",\n          fontSize,\n          imageRendering: \"pixelated\",\n          lineHeight: 1,\n          WebkitBackgroundClip: color === \"source\" ? \"text\" : undefined,\n        }}\n      />\n\n      <span className=\"sr-only\">{alt}</span>\n    </div>\n  );\n};\n\nexport default AsciiRender;\n","path":"index.tsx","target":"components/smoothui/ascii-render/index.tsx","type":"registry:ui"}],"name":"ascii-render","registryDependencies":[],"title":"Ascii Render","type":"registry:ui"}