{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "cursor-reveal",
  "description": "Drag to paint colour back into a B&W photo, or scratch off frosted glass — strokes linger.",
  "dependencies": [
    "three"
  ],
  "registryDependencies": [
    "https://www.pixel-perfect.space/r/animated-texture.json"
  ],
  "files": [
    {
      "path": "registry/new-york/shaders/cursor-reveal.tsx",
      "content": "\"use client\";\n\n/**\n * Drag to paint colour back into a B&W photo, or scratch off frosted glass — strokes linger.\n */\n\n\nimport { useEffect, useRef } from \"react\";\nimport * as THREE from \"three\";\nimport { createAnimatedTexture } from \"./animated-texture\";\n\nconst VERTEX = /* glsl */ `\n  varying vec2 vUv;\n  void main() {\n    vUv = uv;\n    gl_Position = vec4(position.xy, 0.0, 1.0);\n  }\n`;\n\nconst UPDATE_FRAG = /* glsl */ `\n  precision highp float;\n  uniform sampler2D uPrev;\n  uniform vec2 uPoint;      // current cursor (0..1)\n  uniform vec2 uPrevPoint;  // last cursor — paint the whole segment between\n  uniform float uRadius;\n  uniform float uFade;      // per-frame retention (frame-rate normalised)\n  uniform float uAspect;\n  varying vec2 vUv;\n  void main() {\n    float prev = texture2D(uPrev, vUv).r * uFade;\n    vec2 pa = vUv - uPoint;     pa.x *= uAspect;\n    vec2 ba = uPrevPoint - uPoint; ba.x *= uAspect;\n    float h = clamp(dot(pa, ba) / max(dot(ba, ba), 1e-5), 0.0, 1.0);\n    float d = length(pa - ba * h);          // distance to the stroke segment\n    float add = smoothstep(uRadius, 0.0, d);\n    gl_FragColor = vec4(clamp(prev + add, 0.0, 1.0), 0.0, 0.0, 1.0);\n  }\n`;\n\nconst DISPLAY_FRAG = /* glsl */ `\n  precision highp float;\n  uniform sampler2D uTexture;\n  uniform sampler2D uMask;\n  uniform vec2 uTextureRes;\n  uniform vec2 resolution;\n  uniform float uMode;       // 0 = paint, 1 = scratch\n  varying vec2 vUv;\n\n  vec2 coverUV(vec2 uv) {\n    float sa = resolution.x / resolution.y;\n    float ia = uTextureRes.x / uTextureRes.y;\n    vec2 s = vec2(1.0);\n    if (sa > ia) s.y = ia / sa; else s.x = sa / ia;\n    return (uv - 0.5) * s + 0.5;\n  }\n  vec4 texCover(vec2 uv) { return texture2D(uTexture, coverUV(uv)); }\n  float luma(vec3 c) { return dot(c, vec3(0.299, 0.587, 0.114)); }\n\n  void main() {\n    float m = texture2D(uMask, vUv).r;\n    vec3 img = texCover(vUv).rgb;\n    vec3 base;\n    if (uMode < 0.5) {\n      base = vec3(luma(img));                 // paint: grayscale\n    } else {\n      // scratch: cheap frosted glass — blurred, desaturated, lightened\n      vec3 b = texCover(vUv + vec2(0.012, 0.0)).rgb\n             + texCover(vUv - vec2(0.012, 0.0)).rgb\n             + texCover(vUv + vec2(0.0, 0.012)).rgb\n             + texCover(vUv - vec2(0.0, 0.012)).rgb;\n      vec3 blur = (img + b) / 5.0;\n      base = mix(vec3(luma(blur)), vec3(0.92), 0.45) + 0.04;\n    }\n    float reveal = smoothstep(0.12, 0.5, m);\n    gl_FragColor = vec4(mix(base, img, reveal), 1.0);\n  }\n`;\n\nconst CursorReveal = ({\n  image,\n  mode = \"paint\",\n  className,\n  dpr = 2,\n}: {\n  image: string;\n  mode?: \"paint\" | \"scratch\";\n  className?: string;\n  dpr?: number;\n}) => {\n  const containerRef = useRef<HTMLDivElement>(null);\n\n  useEffect(() => {\n    const container = containerRef.current;\n    if (!container) return;\n\n    const renderer = new THREE.WebGLRenderer({ antialias: true, powerPreference: \"low-power\" });\n    renderer.setPixelRatio(Math.min(window.devicePixelRatio, dpr));\n    renderer.autoClear = false;\n    container.appendChild(renderer.domElement);\n    const canvas = renderer.domElement;\n    const onContextLost = (e: Event) => e.preventDefault();\n    canvas.addEventListener(\"webglcontextlost\", onContextLost);\n\n    const camera = new THREE.Camera();\n    const scene = new THREE.Scene();\n    const geometry = new THREE.PlaneGeometry(2, 2);\n    const mesh = new THREE.Mesh(geometry);\n    mesh.frustumCulled = false;\n    scene.add(mesh);\n\n    const MASK = 256;\n    const rtOpts: THREE.RenderTargetOptions = {\n      minFilter: THREE.LinearFilter,\n      magFilter: THREE.LinearFilter,\n      depthBuffer: false,\n      stencilBuffer: false,\n      wrapS: THREE.ClampToEdgeWrapping,\n      wrapT: THREE.ClampToEdgeWrapping,\n    };\n    let maskRead = new THREE.WebGLRenderTarget(MASK, MASK, rtOpts);\n    let maskWrite = new THREE.WebGLRenderTarget(MASK, MASK, rtOpts);\n\n    const animated = createAnimatedTexture(image);\n\n    const updateMat = new THREE.ShaderMaterial({\n      vertexShader: VERTEX,\n      fragmentShader: UPDATE_FRAG,\n      depthTest: false,\n      uniforms: {\n        uPrev: { value: null },\n        uPoint: { value: new THREE.Vector2(0.5, 0.5) },\n        uPrevPoint: { value: new THREE.Vector2(0.5, 0.5) },\n        uRadius: { value: 0.07 },\n        uFade: { value: 1 },\n        uAspect: { value: 1 },\n      },\n    });\n    const displayMat = new THREE.ShaderMaterial({\n      vertexShader: VERTEX,\n      fragmentShader: DISPLAY_FRAG,\n      depthTest: false,\n      uniforms: {\n        uTexture: { value: animated.texture },\n        uMask: { value: null },\n        uTextureRes: { value: new THREE.Vector2(1, 1) },\n        resolution: { value: new THREE.Vector2() },\n        uMode: { value: mode === \"scratch\" ? 1 : 0 },\n      },\n    });\n\n    const blit = (m: THREE.Material, target: THREE.WebGLRenderTarget | null) => {\n      mesh.material = m;\n      renderer.setRenderTarget(target);\n      renderer.render(scene, camera);\n    };\n\n    let point: { x: number; y: number } | null = null;\n    let prevPoint = { x: 0.5, y: 0.5 };\n    let lastInteraction = -Infinity;\n    const onMove = (e: PointerEvent) => {\n      const r = canvas.getBoundingClientRect();\n      if (!r.width || !r.height) return;\n      point = { x: (e.clientX - r.left) / r.width, y: 1 - (e.clientY - r.top) / r.height };\n      lastInteraction = shaderTime;\n    };\n    const onLeave = () => { point = null; };\n    canvas.addEventListener(\"pointermove\", onMove);\n    canvas.addEventListener(\"pointerleave\", onLeave);\n\n    const fadeBase = mode === \"scratch\" ? 0.996 : 0.97;\n\n    let shaderTime = 0;\n    let last = performance.now();\n    const frame = () => {\n      const now = performance.now();\n      const dt = Math.min((now - last) / 1000, 1 / 30);\n      last = now;\n      shaderTime += dt;\n\n      animated.update(shaderTime * 1000);\n      const img = animated.texture.image as { width: number; height: number };\n      if (img && img.width > 1) displayMat.uniforms.uTextureRes.value.set(img.width, img.height);\n\n      let cur = point;\n      if (!cur && shaderTime - lastInteraction > 0.8) {\n        const t = shaderTime * 0.8;\n        cur = { x: 0.5 + 0.33 * Math.sin(t * 1.3), y: 0.5 + 0.3 * Math.cos(t * 0.9) };\n      }\n      const u = updateMat.uniforms;\n      if (cur) {\n        u.uPoint.value.set(cur.x, cur.y);\n        u.uPrevPoint.value.set(prevPoint.x, prevPoint.y);\n        prevPoint = cur;\n      } else {\n        u.uPoint.value.set(-1, -1);\n        u.uPrevPoint.value.set(-1, -1);\n      }\n      u.uAspect.value = displayMat.uniforms.resolution.value.x /\n        Math.max(displayMat.uniforms.resolution.value.y, 1);\n      u.uFade.value = Math.pow(fadeBase, dt * 60);\n      u.uPrev.value = maskRead.texture;\n      blit(updateMat, maskWrite);\n      const tmp = maskRead; maskRead = maskWrite; maskWrite = tmp;\n\n      displayMat.uniforms.uMask.value = maskRead.texture;\n      blit(displayMat, null);\n    };\n\n    const resize = () => {\n      const w = container.clientWidth, h = container.clientHeight;\n      if (!w || !h) return;\n      renderer.setSize(w, h);\n      displayMat.uniforms.resolution.value.set(renderer.domElement.width, renderer.domElement.height);\n      frame();\n    };\n    resize();\n    const ro = new ResizeObserver(resize);\n    ro.observe(container);\n\n    let visible = true;\n    const io = new IntersectionObserver(([e]) => { visible = e.isIntersecting; last = performance.now(); }, { rootMargin: \"150px\", threshold: 0 });\n    io.observe(container);\n\n    let raf = 0;\n    const animate = () => { raf = requestAnimationFrame(animate); if (visible) frame(); };\n    animate();\n\n    return () => {\n      cancelAnimationFrame(raf);\n      ro.disconnect();\n      io.disconnect();\n      canvas.removeEventListener(\"webglcontextlost\", onContextLost);\n      canvas.removeEventListener(\"pointermove\", onMove);\n      canvas.removeEventListener(\"pointerleave\", onLeave);\n      if (canvas.parentNode === container) container.removeChild(canvas);\n      maskRead.dispose();\n      maskWrite.dispose();\n      animated.dispose();\n      geometry.dispose();\n      updateMat.dispose();\n      displayMat.dispose();\n      renderer.setRenderTarget(null);\n      renderer.forceContextLoss();\n      renderer.dispose();\n    };\n  }, [image, mode, dpr]);\n\n  return <div ref={containerRef} aria-hidden className={className} style={{ overflow: \"hidden\" }} />;\n};\n\nexport default CursorReveal;\n",
      "type": "registry:component",
      "target": "components/pixel-perfect/cursor-reveal.tsx"
    }
  ],
  "type": "registry:block"
}