{
  "name": "dither-shader",
  "type": "registry:ui",
  "files": [
    {
      "path": "components/ui/dither-shader.tsx",
      "content": "\"use client\";\nimport React, { useEffect, useRef, useCallback, useState } from \"react\";\nimport { cn } from \"@/lib/utils\";\n\ntype DitheringMode = \"bayer\" | \"halftone\" | \"noise\" | \"crosshatch\";\ntype ColorMode = \"original\" | \"grayscale\" | \"duotone\" | \"custom\";\n\ninterface DitherShaderProps {\n  /** Source image URL */\n  src: string;\n  /** Size of the dithering grid cells */\n  gridSize?: number;\n  /** Type of dithering pattern */\n  ditherMode?: DitheringMode;\n  /** Color processing mode */\n  colorMode?: ColorMode;\n  /** Invert the dithered output colors */\n  invert?: boolean;\n  /** Pixelation multiplier (1 = no pixelation, higher = more pixelated) */\n  pixelRatio?: number;\n  /** Primary color for duotone mode */\n  primaryColor?: string;\n  /** Secondary color for duotone mode */\n  secondaryColor?: string;\n  /** Custom color palette array for custom mode */\n  customPalette?: string[];\n  /** Brightness adjustment (-1 to 1) */\n  brightness?: number;\n  /** Contrast adjustment (0 to 2, 1 = normal) */\n  contrast?: number;\n  /** Background color behind the dithered image */\n  backgroundColor?: string;\n  /** Object fit behavior */\n  objectFit?: \"cover\" | \"contain\" | \"fill\" | \"none\";\n  /** Threshold bias for dithering (0 to 1) */\n  threshold?: number;\n  /** Enable animation effect */\n  animated?: boolean;\n  /** Animation speed (lower = slower) */\n  animationSpeed?: number;\n  /** Additional CSS classes for the container (use this to set size via Tailwind) */\n  className?: string;\n}\n\n// 4x4 Bayer matrix for ordered dithering\nconst BAYER_MATRIX_4x4 = [\n  [0, 8, 2, 10],\n  [12, 4, 14, 6],\n  [3, 11, 1, 9],\n  [15, 7, 13, 5],\n];\n\n// 8x8 Bayer matrix for finer dithering\nconst BAYER_MATRIX_8x8 = [\n  [0, 32, 8, 40, 2, 34, 10, 42],\n  [48, 16, 56, 24, 50, 18, 58, 26],\n  [12, 44, 4, 36, 14, 46, 6, 38],\n  [60, 28, 52, 20, 62, 30, 54, 22],\n  [3, 35, 11, 43, 1, 33, 9, 41],\n  [51, 19, 59, 27, 49, 17, 57, 25],\n  [15, 47, 7, 39, 13, 45, 5, 37],\n  [63, 31, 55, 23, 61, 29, 53, 21],\n];\n\nfunction parseColor(color: string): [number, number, number] {\n  if (color.startsWith(\"#\")) {\n    const hex = color.slice(1);\n    if (hex.length === 3) {\n      return [\n        parseInt(hex[0] + hex[0], 16),\n        parseInt(hex[1] + hex[1], 16),\n        parseInt(hex[2] + hex[2], 16),\n      ];\n    }\n    return [\n      parseInt(hex.slice(0, 2), 16),\n      parseInt(hex.slice(2, 4), 16),\n      parseInt(hex.slice(4, 6), 16),\n    ];\n  }\n  const match = color.match(/rgb\\((\\d+)\\s*,\\s*(\\d+)\\s*,\\s*(\\d+)\\)/i);\n  if (match) {\n    return [parseInt(match[1]), parseInt(match[2]), parseInt(match[3])];\n  }\n  return [0, 0, 0];\n}\n\nfunction getLuminance(r: number, g: number, b: number): number {\n  return 0.299 * r + 0.587 * g + 0.114 * b;\n}\n\nfunction clamp(value: number, min: number, max: number): number {\n  return Math.max(min, Math.min(max, value));\n}\n\nexport const DitherShader: React.FC<DitherShaderProps> = ({\n  src,\n  gridSize = 4,\n  ditherMode = \"bayer\",\n  colorMode = \"original\",\n  invert = false,\n  pixelRatio = 1,\n  primaryColor = \"#000000\",\n  secondaryColor = \"#ffffff\",\n  customPalette = [\"#000000\", \"#ffffff\"],\n  brightness = 0,\n  contrast = 1,\n  backgroundColor = \"transparent\",\n  objectFit = \"cover\",\n  threshold = 0.5,\n  animated = false,\n  animationSpeed = 0.02,\n  className,\n}) => {\n  const containerRef = useRef<HTMLDivElement>(null);\n  const canvasRef = useRef<HTMLCanvasElement>(null);\n  const animationRef = useRef<number | null>(null);\n  const timeRef = useRef<number>(0);\n  const imageRef = useRef<HTMLImageElement | null>(null);\n  const imageDataRef = useRef<ImageData | null>(null);\n  const dimensionsRef = useRef<{ width: number; height: number }>({\n    width: 0,\n    height: 0,\n  });\n\n  const [dimensions, setDimensions] = useState<{\n    width: number;\n    height: number;\n  }>({ width: 0, height: 0 });\n\n  const parsedPrimaryColor = parseColor(primaryColor);\n  const parsedSecondaryColor = parseColor(secondaryColor);\n  const parsedCustomPalette = customPalette.map(parseColor);\n\n  const applyDithering = useCallback(\n    (\n      ctx: CanvasRenderingContext2D,\n      displayWidth: number,\n      displayHeight: number,\n      time: number = 0,\n    ) => {\n      const canvas = canvasRef.current;\n      if (!canvas || !imageDataRef.current) return;\n\n      // Clear with background\n      if (backgroundColor !== \"transparent\") {\n        ctx.fillStyle = backgroundColor;\n        ctx.fillRect(0, 0, displayWidth, displayHeight);\n      } else {\n        ctx.clearRect(0, 0, displayWidth, displayHeight);\n      }\n\n      const sourceData = imageDataRef.current.data;\n      const sourceWidth = imageDataRef.current.width;\n      const sourceHeight = imageDataRef.current.height;\n\n      const effectivePixelSize = Math.max(1, Math.floor(gridSize * pixelRatio));\n      const matrixSize = gridSize <= 4 ? 4 : 8;\n      const bayerMatrix = gridSize <= 4 ? BAYER_MATRIX_4x4 : BAYER_MATRIX_8x8;\n      const matrixScale = matrixSize === 4 ? 16 : 64;\n\n      // Process pixels\n      for (let y = 0; y < displayHeight; y += effectivePixelSize) {\n        for (let x = 0; x < displayWidth; x += effectivePixelSize) {\n          // Map display coordinates to source image coordinates\n          const srcX = Math.floor((x / displayWidth) * sourceWidth);\n          const srcY = Math.floor((y / displayHeight) * sourceHeight);\n          const srcIdx = (srcY * sourceWidth + srcX) * 4;\n\n          let r = sourceData[srcIdx] || 0;\n          let g = sourceData[srcIdx + 1] || 0;\n          let b = sourceData[srcIdx + 2] || 0;\n          const a = sourceData[srcIdx + 3] || 0;\n\n          if (a < 10) continue; // Skip fully transparent pixels\n\n          // Apply brightness and contrast\n          r = clamp((r - 128) * contrast + 128 + brightness * 255, 0, 255);\n          g = clamp((g - 128) * contrast + 128 + brightness * 255, 0, 255);\n          b = clamp((b - 128) * contrast + 128 + brightness * 255, 0, 255);\n\n          // Calculate luminance\n          const luminance = getLuminance(r, g, b) / 255;\n\n          // Get dither threshold based on mode\n          let ditherThreshold: number;\n          const matrixX = Math.floor(x / gridSize) % matrixSize;\n          const matrixY = Math.floor(y / gridSize) % matrixSize;\n\n          switch (ditherMode) {\n            case \"bayer\":\n              ditherThreshold = bayerMatrix[matrixY][matrixX] / matrixScale;\n              break;\n            case \"halftone\": {\n              const angle = Math.PI / 4;\n              const scale = gridSize * 2;\n              const rotX = x * Math.cos(angle) + y * Math.sin(angle);\n              const rotY = -x * Math.sin(angle) + y * Math.cos(angle);\n              const pattern =\n                (Math.sin(rotX / scale) + Math.sin(rotY / scale) + 2) / 4;\n              ditherThreshold = pattern;\n              break;\n            }\n            case \"noise\": {\n              const noiseVal =\n                Math.sin(x * 12.9898 + y * 78.233 + time * 100) * 43758.5453;\n              ditherThreshold = noiseVal - Math.floor(noiseVal);\n              break;\n            }\n            case \"crosshatch\": {\n              const line1 = (x + y) % (gridSize * 2) < gridSize ? 1 : 0;\n              const line2 =\n                (x - y + gridSize * 4) % (gridSize * 2) < gridSize ? 1 : 0;\n              ditherThreshold = (line1 + line2) / 2;\n              break;\n            }\n            default:\n              ditherThreshold = bayerMatrix[matrixY][matrixX] / matrixScale;\n          }\n\n          // Adjust threshold with user setting\n          ditherThreshold = ditherThreshold * (1 - threshold) + threshold * 0.5;\n\n          // Determine output color based on color mode\n          let outputColor: [number, number, number];\n\n          switch (colorMode) {\n            case \"grayscale\": {\n              const shouldBeDark = luminance < ditherThreshold;\n              outputColor = shouldBeDark ? [0, 0, 0] : [255, 255, 255];\n              break;\n            }\n            case \"duotone\": {\n              const shouldBeDark = luminance < ditherThreshold;\n              outputColor = shouldBeDark\n                ? parsedPrimaryColor\n                : parsedSecondaryColor;\n              break;\n            }\n            case \"custom\": {\n              if (parsedCustomPalette.length === 2) {\n                const shouldBeDark = luminance < ditherThreshold;\n                outputColor = shouldBeDark\n                  ? parsedCustomPalette[0]\n                  : parsedCustomPalette[1];\n              } else {\n                // Quantize to closest palette color with dithering\n                const adjustedLuminance =\n                  luminance + (ditherThreshold - 0.5) * 0.5;\n                const paletteIndex = Math.floor(\n                  clamp(adjustedLuminance, 0, 1) *\n                    (parsedCustomPalette.length - 1),\n                );\n                outputColor = parsedCustomPalette[paletteIndex];\n              }\n              break;\n            }\n            case \"original\":\n            default: {\n              // Apply dithering while preserving colors\n              const ditherAmount = ditherThreshold - 0.5;\n              const adjustedR = clamp(r + ditherAmount * 64, 0, 255);\n              const adjustedG = clamp(g + ditherAmount * 64, 0, 255);\n              const adjustedB = clamp(b + ditherAmount * 64, 0, 255);\n\n              // Quantize to fewer levels for dithered look\n              const levels = 4;\n              outputColor = [\n                Math.round(adjustedR / (255 / levels)) * (255 / levels),\n                Math.round(adjustedG / (255 / levels)) * (255 / levels),\n                Math.round(adjustedB / (255 / levels)) * (255 / levels),\n              ];\n              break;\n            }\n          }\n\n          // Apply inversion\n          if (invert) {\n            outputColor = [\n              255 - outputColor[0],\n              255 - outputColor[1],\n              255 - outputColor[2],\n            ];\n          }\n\n          // Draw the pixel\n          ctx.fillStyle = `rgb(${outputColor[0]}, ${outputColor[1]}, ${outputColor[2]})`;\n          ctx.fillRect(x, y, effectivePixelSize, effectivePixelSize);\n        }\n      }\n    },\n    [\n      gridSize,\n      ditherMode,\n      colorMode,\n      invert,\n      pixelRatio,\n      parsedPrimaryColor,\n      parsedSecondaryColor,\n      parsedCustomPalette,\n      brightness,\n      contrast,\n      backgroundColor,\n      threshold,\n    ],\n  );\n\n  // Setup resize observer for responsive sizing\n  useEffect(() => {\n    const container = containerRef.current;\n    if (!container) return;\n\n    const resizeObserver = new ResizeObserver((entries) => {\n      for (const entry of entries) {\n        const { width, height } = entry.contentRect;\n        if (width > 0 && height > 0) {\n          dimensionsRef.current = { width, height };\n          setDimensions({ width, height });\n        }\n      }\n    });\n\n    resizeObserver.observe(container);\n\n    return () => {\n      resizeObserver.disconnect();\n    };\n  }, []);\n\n  // Process image and apply dithering when dimensions or settings change\n  useEffect(() => {\n    const canvas = canvasRef.current;\n    if (!canvas || dimensions.width === 0 || dimensions.height === 0) return;\n\n    let isCancelled = false;\n\n    const processImage = (img: HTMLImageElement) => {\n      if (isCancelled) return;\n\n      const dpr =\n        typeof window !== \"undefined\" ? window.devicePixelRatio || 1 : 1;\n      const displayWidth = dimensions.width;\n      const displayHeight = dimensions.height;\n\n      canvas.width = Math.floor(displayWidth * dpr);\n      canvas.height = Math.floor(displayHeight * dpr);\n\n      const ctx = canvas.getContext(\"2d\");\n      if (!ctx) return;\n      ctx.resetTransform();\n      ctx.scale(dpr, dpr);\n\n      // Create offscreen canvas to get image data\n      const offscreen = document.createElement(\"canvas\");\n      const iw = img.naturalWidth || displayWidth;\n      const ih = img.naturalHeight || displayHeight;\n\n      let dw = displayWidth;\n      let dh = displayHeight;\n      let dx = 0;\n      let dy = 0;\n\n      if (objectFit === \"cover\") {\n        const scale = Math.max(displayWidth / iw, displayHeight / ih);\n        dw = Math.ceil(iw * scale);\n        dh = Math.ceil(ih * scale);\n        dx = Math.floor((displayWidth - dw) / 2);\n        dy = Math.floor((displayHeight - dh) / 2);\n      } else if (objectFit === \"contain\") {\n        const scale = Math.min(displayWidth / iw, displayHeight / ih);\n        dw = Math.ceil(iw * scale);\n        dh = Math.ceil(ih * scale);\n        dx = Math.floor((displayWidth - dw) / 2);\n        dy = Math.floor((displayHeight - dh) / 2);\n      } else if (objectFit === \"fill\") {\n        dw = displayWidth;\n        dh = displayHeight;\n      } else {\n        dw = iw;\n        dh = ih;\n        dx = Math.floor((displayWidth - dw) / 2);\n        dy = Math.floor((displayHeight - dh) / 2);\n      }\n\n      offscreen.width = displayWidth;\n      offscreen.height = displayHeight;\n      const offCtx = offscreen.getContext(\"2d\");\n      if (!offCtx) return;\n\n      offCtx.drawImage(img, dx, dy, dw, dh);\n\n      try {\n        imageDataRef.current = offCtx.getImageData(\n          0,\n          0,\n          displayWidth,\n          displayHeight,\n        );\n      } catch {\n        console.error(\"Could not get image data. CORS issue?\");\n        return;\n      }\n\n      // Initial render\n      applyDithering(ctx, displayWidth, displayHeight, 0);\n\n      // Setup animation if enabled\n      if (animated) {\n        const animate = () => {\n          if (isCancelled) return;\n          timeRef.current += animationSpeed;\n          applyDithering(ctx, displayWidth, displayHeight, timeRef.current);\n          animationRef.current = requestAnimationFrame(animate);\n        };\n        animationRef.current = requestAnimationFrame(animate);\n      }\n    };\n\n    // If image is already loaded, reprocess it\n    if (imageRef.current && imageRef.current.complete) {\n      processImage(imageRef.current);\n    } else {\n      // Load the image\n      const img = new Image();\n      img.crossOrigin = \"anonymous\";\n      img.src = src;\n\n      img.onload = () => {\n        if (isCancelled) return;\n        imageRef.current = img;\n        processImage(img);\n      };\n\n      img.onerror = () => {\n        console.error(\"Failed to load image for DitherShader:\", src);\n      };\n    }\n\n    return () => {\n      isCancelled = true;\n      if (animationRef.current) {\n        cancelAnimationFrame(animationRef.current);\n      }\n    };\n  }, [src, dimensions, objectFit, animated, animationSpeed, applyDithering]);\n\n  return (\n    <div ref={containerRef} className={cn(\"relative h-full w-full\", className)}>\n      <canvas\n        ref={canvasRef}\n        className=\"absolute inset-0 h-full w-full\"\n        style={{ imageRendering: \"pixelated\" }}\n        aria-label=\"Dithered image\"\n        role=\"img\"\n      />\n    </div>\n  );\n};\n\nexport default DitherShader;\n",
      "type": "registry:ui",
      "target": "components/ui/dither-shader.tsx"
    }
  ],
  "author": "Manu Arora <hi@manuarora.in>",
  "title": "Dither Shader"
}