{"name":"chromatic-image","type":"registry:component","files":[{"path":"chromatic-image.tsx","type":"registry:component","target":"components/shaders/chromatic-image.tsx","content":"\"use client\";\n\nimport React, { useEffect, useRef, useState } from \"react\";\n\nexport type ChromaticImageProps = {\n  src: string;\n  alt: string;\n  children?: React.ReactNode;\n  className?: string;\n  backgroundColor?: string;\n  zoom?: number;\n  displacement?: number;\n  chromaticShift?: number;\n  tilt?: number;\n};\n\nconst VERTEX_SHADER = `\nattribute vec2 aPosition;\nvarying vec2 vUv;\n\nvoid main() {\n  vUv = aPosition * 0.5 + 0.5;\n  gl_Position = vec4(aPosition, 0.0, 1.0);\n}\n`;\n\nconst FRAGMENT_SHADER = `\nprecision highp float;\n\nuniform sampler2D uImage;\nuniform vec2 uPointer;\nuniform float uImageAspect;\nuniform float uCanvasAspect;\nuniform float uProgress;\nuniform float uZoom;\nuniform float uWarp;\nuniform float uChromatic;\nvarying vec2 vUv;\n\nvec2 cover(vec2 uv) {\n  if (uImageAspect > uCanvasAspect) {\n    uv.x = (uv.x - 0.5) * uCanvasAspect / uImageAspect + 0.5;\n  } else {\n    uv.y = (uv.y - 0.5) * uImageAspect / uCanvasAspect + 0.5;\n  }\n  return uv;\n}\n\nvoid main() {\n  float strength = uProgress;\n  vec2 movement = (uPointer - vec2(0.5)) * vec2(uCanvasAspect, 1.0);\n  vec2 direction = movement / max(length(movement), 0.2);\n\n  vec2 baseUv = mix(vUv, vec2(0.5), uZoom * uProgress * 0.28);\n  float band = sin(vUv.y * 24.0 + uPointer.x * 5.0);\n  float fineBand = sin(vUv.y * 71.0 - uPointer.y * 4.0);\n  baseUv.x += (band * 0.72 + fineBand * 0.28) * uWarp * strength * 0.16;\n  baseUv.y += direction.y * uWarp * strength * 0.12;\n  baseUv = cover(baseUv);\n\n  vec2 split = direction * uChromatic * strength;\n  split.x += band * uChromatic * strength * 0.35;\n  float red = texture2D(uImage, clamp(baseUv + split, 0.0, 1.0)).r;\n  float green = texture2D(uImage, clamp(baseUv, 0.0, 1.0)).g;\n  float blue = texture2D(uImage, clamp(baseUv - split, 0.0, 1.0)).b;\n  float alpha = texture2D(uImage, clamp(baseUv, 0.0, 1.0)).a;\n\n  gl_FragColor = vec4(red, green, blue, alpha);\n}\n`;\n\nfunction createShader(\n  gl: WebGLRenderingContext,\n  type: number,\n  source: string,\n) {\n  const shader = gl.createShader(type);\n  if (!shader) return null;\n  gl.shaderSource(shader, source);\n  gl.compileShader(shader);\n  if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {\n    gl.deleteShader(shader);\n    return null;\n  }\n  return shader;\n}\n\nfunction approach(current: number, target: number, speed: number, delta: number) {\n  return current + (target - current) * (1 - Math.exp(-speed * delta));\n}\n\nfunction parseColor(color: string): [number, number, number] {\n  const value = color.startsWith(\"#\") ? color.slice(1) : \"111111\";\n  const hex = value.length === 3\n    ? value\n        .split(\"\")\n        .map((character) => character + character)\n        .join(\"\")\n    : value;\n  return [\n    parseInt(hex.slice(0, 2), 16) / 255,\n    parseInt(hex.slice(2, 4), 16) / 255,\n    parseInt(hex.slice(4, 6), 16) / 255,\n  ];\n}\n\nexport function ChromaticImage({\n  src,\n  alt,\n  children,\n  className,\n  backgroundColor = \"#111111\",\n  zoom = 0.2,\n  displacement = 0.05,\n  chromaticShift = 0.01,\n  tilt = 0.3,\n}: ChromaticImageProps) {\n  const containerRef = useRef<HTMLDivElement>(null);\n  const canvasRef = useRef<HTMLCanvasElement>(null);\n  const [ready, setReady] = useState(false);\n\n  useEffect(() => {\n    const container = containerRef.current;\n    const canvas = canvasRef.current;\n    if (!container || !canvas) return;\n\n    setReady(false);\n    const gl = canvas.getContext(\"webgl\", {\n      alpha: false,\n      antialias: false,\n      premultipliedAlpha: false,\n    });\n    if (!gl) return;\n\n    const vertex = createShader(gl, gl.VERTEX_SHADER, VERTEX_SHADER);\n    const fragment = createShader(gl, gl.FRAGMENT_SHADER, FRAGMENT_SHADER);\n    if (!vertex || !fragment) return;\n\n    const program = gl.createProgram();\n    if (!program) return;\n    gl.attachShader(program, vertex);\n    gl.attachShader(program, fragment);\n    gl.linkProgram(program);\n    if (!gl.getProgramParameter(program, gl.LINK_STATUS)) return;\n    gl.useProgram(program);\n\n    const position = gl.getAttribLocation(program, \"aPosition\");\n    const buffer = gl.createBuffer();\n    gl.bindBuffer(gl.ARRAY_BUFFER, buffer);\n    gl.bufferData(\n      gl.ARRAY_BUFFER,\n      new Float32Array([-1, -1, 3, -1, -1, 3]),\n      gl.STATIC_DRAW,\n    );\n    gl.enableVertexAttribArray(position);\n    gl.vertexAttribPointer(position, 2, gl.FLOAT, false, 0, 0);\n\n    const uniforms = {\n      image: gl.getUniformLocation(program, \"uImage\"),\n      pointer: gl.getUniformLocation(program, \"uPointer\"),\n      imageAspect: gl.getUniformLocation(program, \"uImageAspect\"),\n      canvasAspect: gl.getUniformLocation(program, \"uCanvasAspect\"),\n      progress: gl.getUniformLocation(program, \"uProgress\"),\n      zoom: gl.getUniformLocation(program, \"uZoom\"),\n      warp: gl.getUniformLocation(program, \"uWarp\"),\n      chromatic: gl.getUniformLocation(program, \"uChromatic\"),\n    };\n\n    gl.uniform1i(uniforms.image, 0);\n    gl.uniform1f(uniforms.zoom, zoom);\n    gl.uniform1f(uniforms.warp, displacement);\n    gl.uniform1f(uniforms.chromatic, chromaticShift);\n\n    const texture = gl.createTexture();\n    gl.activeTexture(gl.TEXTURE0);\n    gl.bindTexture(gl.TEXTURE_2D, texture);\n    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);\n    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);\n    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);\n    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);\n\n    const [red, green, blue] = parseColor(backgroundColor);\n    gl.clearColor(red, green, blue, 1);\n    gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, 1);\n\n    const pointer = { x: 0.5, y: 0.5 };\n    const pointerTarget = { x: 0.5, y: 0.5 };\n    let progress = 0;\n    let progressTarget = 0;\n    let imageLoaded = false;\n    let disposed = false;\n    let frame = 0;\n    let isRendering = false;\n    let previousTime = performance.now();\n    const reduceMotion = window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n\n    const resize = () => {\n      const width = canvas.clientWidth;\n      const height = canvas.clientHeight;\n      if (!width || !height) return;\n      const pixelRatio = Math.min(window.devicePixelRatio || 1, 2);\n      const renderWidth = Math.round(width * pixelRatio);\n      const renderHeight = Math.round(height * pixelRatio);\n      if (canvas.width !== renderWidth || canvas.height !== renderHeight) {\n        canvas.width = renderWidth;\n        canvas.height = renderHeight;\n        gl.viewport(0, 0, renderWidth, renderHeight);\n      }\n      gl.uniform1f(uniforms.canvasAspect, width / height);\n    };\n\n    const render = (time: number) => {\n      if (disposed) return;\n      const delta = Math.min((time - previousTime) / 1000, 0.05);\n      previousTime = time;\n      progress = approach(progress, progressTarget, 10, delta);\n      pointer.x = approach(pointer.x, pointerTarget.x, 30, delta);\n      pointer.y = approach(pointer.y, pointerTarget.y, 30, delta);\n\n      gl.uniform1f(uniforms.progress, reduceMotion ? 0 : progress);\n      gl.uniform2f(uniforms.pointer, pointer.x, pointer.y);\n      gl.clear(gl.COLOR_BUFFER_BIT);\n      if (imageLoaded) gl.drawArrays(gl.TRIANGLES, 0, 3);\n\n      const rotateX = reduceMotion ? 0 : (0.5 - pointer.y) * tilt * 18;\n      const rotateY = reduceMotion ? 0 : (pointer.x - 0.5) * tilt * 18;\n      canvas.style.transform = `perspective(900px) rotateX(${rotateX}deg) rotateY(${rotateY}deg) scale(1.025)`;\n\n      const isSettled =\n        Math.abs(progress - progressTarget) < 0.001 &&\n        Math.abs(pointer.x - pointerTarget.x) < 0.001 &&\n        Math.abs(pointer.y - pointerTarget.y) < 0.001;\n      if (isSettled) {\n        isRendering = false;\n      } else {\n        frame = requestAnimationFrame(render);\n      }\n    };\n\n    const requestRender = () => {\n      if (isRendering) return;\n      isRendering = true;\n      previousTime = performance.now();\n      frame = requestAnimationFrame(render);\n    };\n\n    const updatePointer = (event: PointerEvent) => {\n      const bounds = container.getBoundingClientRect();\n      pointerTarget.x = (event.clientX - bounds.left) / bounds.width;\n      pointerTarget.y = 1 - (event.clientY - bounds.top) / bounds.height;\n      progressTarget = 1;\n      requestRender();\n    };\n\n    const resetPointer = () => {\n      pointerTarget.x = 0.5;\n      pointerTarget.y = 0.5;\n      progressTarget = 0;\n      requestRender();\n    };\n\n    const image = new Image();\n    image.crossOrigin = \"anonymous\";\n    image.onload = () => {\n      if (disposed) return;\n      gl.bindTexture(gl.TEXTURE_2D, texture);\n      gl.texImage2D(\n        gl.TEXTURE_2D,\n        0,\n        gl.RGBA,\n        gl.RGBA,\n        gl.UNSIGNED_BYTE,\n        image,\n      );\n      gl.uniform1f(uniforms.imageAspect, image.naturalWidth / image.naturalHeight);\n      imageLoaded = true;\n      setReady(true);\n      requestRender();\n    };\n    image.onerror = () => setReady(false);\n    image.src = src;\n\n    const resizeObserver = new ResizeObserver(() => {\n      resize();\n      requestRender();\n    });\n    resizeObserver.observe(container);\n    container.addEventListener(\"pointermove\", updatePointer, { passive: true });\n    container.addEventListener(\"pointerleave\", resetPointer, { passive: true });\n    resize();\n    requestRender();\n\n    return () => {\n      disposed = true;\n      cancelAnimationFrame(frame);\n      resizeObserver.disconnect();\n      container.removeEventListener(\"pointermove\", updatePointer);\n      container.removeEventListener(\"pointerleave\", resetPointer);\n      canvas.style.transform = \"\";\n      gl.deleteTexture(texture);\n      gl.deleteBuffer(buffer);\n      gl.deleteProgram(program);\n      gl.deleteShader(vertex);\n      gl.deleteShader(fragment);\n    };\n  }, [backgroundColor, displacement, chromaticShift, src, tilt, zoom]);\n\n  return (\n    <div\n      ref={containerRef}\n      className={`relative isolate overflow-hidden bg-neutral-100 dark:bg-neutral-900 ${className ?? \"\"}`.trim()}\n    >\n      <img\n        src={src}\n        alt={alt}\n        className={`absolute inset-0 size-full object-cover transition-opacity duration-300 ${ready ? \"opacity-0\" : \"opacity-100\"}`}\n      />\n      <canvas\n        ref={canvasRef}\n        aria-hidden=\"true\"\n        className={`absolute -inset-[2.5%] size-[105%] will-change-transform transition-opacity duration-300 ${ready ? \"opacity-100\" : \"opacity-0\"}`}\n      />\n      {children}\n    </div>\n  );\n}\n\nexport default ChromaticImage;\n"}]}