{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "shader-riso",
  "title": "Shader Riso",
  "author": "Visant Labs <visantsupply@gmail.com>",
  "description": "Uma imagem impressa em duas tintas de risografia, com grão, ângulo próprio por tinta e registro fora do lugar.",
  "dependencies": [],
  "registryDependencies": [
    "utils",
    "@visant/css-color"
  ],
  "files": [
    {
      "path": "registry/visant/ui/shader-riso.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\nimport { onThemeChange, resolveCssColor } from \"@/lib/css-color\";\n\n/**\n * Shader Riso — uma imagem impressa em duas tintas de risografia: a marca nos\n * tons e uma tinta escura só nas sombras, cada uma com seu ângulo, seu grão e\n * um registro que não bate direito, como sai da máquina de verdade.\n *\n * Colhido do motor de efeitos de impressão do Visant Labs (`print-fx`, o\n * shader de risografia que o estúdio e o servidor rodam igual). Lá são até\n * quatro tintas escolhidas pelo usuário; aqui são duas, tiradas do tema.\n *\n * ── As decisões que não se veem ────────────────────────────────────────────\n *\n * 1. **Separação por cobertura, não por cor da tinta.** O original separa cada\n *    tinta medindo a distância do pixel até a cor dela sobre o papel, o que\n *    pressupõe papel claro. Aqui a primeira tinta pega toda a cobertura\n *    (1 − luminância) e a segunda só o terço mais escuro. Funciona com papel\n *    escuro no tema escuro, e a imagem continua legível nas duas.\n * 2. **Sobrepor, não multiplicar.** Tinta de riso multiplica no papel branco;\n *    no papel escuro a multiplicação apaga tudo. As camadas entram por cima\n *    (`mix`), o que lê como serigrafia no escuro e como riso no claro.\n * 3. **As três retículas do original ficam.** Grão estocástico (o riso), ponto\n *    de retícula e Bayer, escolhidos por `mode`, com o hash e a queda de tinta\n *    (`dropout`) de lá. É o que dá falha de máquina e não de filtro.\n * 4. **O cursor puxa a folha.** Perto do ponteiro as duas tintas escorregam\n *    para lados opostos, como papel que andou na máquina: o registro abre ali\n *    e fecha de novo quando o cursor sai. Só mexe no deslocamento, nunca na\n *    retícula, então o grão não treme.\n * 5. **Desenha sob demanda.** Imagem parada não pede quadro: desenha na carga,\n *    no tema, no tamanho e no knob. O laço só existe enquanto o puxão entra ou\n *    sai, e com movimento reduzido ele aparece sem transição.\n * 6. **Fonte viva.** Com `source` (um canvas ou vídeo, por exemplo o do\n *    `lathe-object`), a textura sobe a cada quadro, só com a peça na tela e a aba\n *    visível. O alfa da fonte vira papel. Com movimento reduzido, sobe um quadro\n *    só, e outro a cada gesto do ponteiro. Canvas WebGL como fonte precisa de\n *    `preserveDrawingBuffer` (no lathe-object, a prop de mesmo nome), senão o\n *    navegador pode entregar o buffer já limpo.\n */\n\nconst VERT = `attribute vec2 a_pos;void main(){gl_Position=vec4(a_pos,0.0,1.0);}`;\n\nconst FRAG = `\n#ifdef GL_FRAGMENT_PRECISION_HIGH\nprecision highp float;\n#else\nprecision mediump float;\n#endif\nuniform vec2 uRes;\nuniform vec2 uImg;\nuniform sampler2D uTex;\nuniform float uCell;\nuniform float uMis;\nuniform float uModo;\nuniform float uRuido;\nuniform vec3 uPonteiro;\nuniform float uRaio;\nuniform vec3 uPapel;\nuniform vec3 uTinta0;\nuniform vec3 uTinta1;\n\nfloat hash(vec2 p){\n  p=50.*fract(p*.3183099+vec2(.71,.113));\n  return fract(p.x*p.y*(p.x+p.y));\n}\n\nfloat cobertura(vec2 px){\n  float s=max(uRes.x/uImg.x,uRes.y/uImg.y);\n  vec2 tam=uImg*s;\n  vec2 uv=(px-(uRes-tam)*.5)/tam;\n  if(uv.x<0.||uv.y<0.||uv.x>1.||uv.y>1.)return 0.;\n  vec4 c=texture2D(uTex,uv);\n  float lum=dot(c.rgb,vec3(.299,.587,.114));\n  return clamp(((1.-lum)*c.a-.5)*1.4+.5,0.,1.);\n}\n\nvec2 girar(vec2 px,float graus){\n  float r=radians(graus);\n  vec2 c=px-uRes*.5;\n  return mat2(cos(r),-sin(r),sin(r),cos(r))*c+uRes*.5;\n}\n\nfloat bayer4(vec2 p){\n  float i=mod(p.x,4.)+mod(p.y,4.)*4.;\n  if(i<.5)return 0.;if(i<1.5)return 8.;if(i<2.5)return 2.;if(i<3.5)return 10.;\n  if(i<4.5)return 12.;if(i<5.5)return 4.;if(i<6.5)return 14.;if(i<7.5)return 6.;\n  if(i<8.5)return 3.;if(i<9.5)return 11.;if(i<10.5)return 1.;if(i<11.5)return 9.;\n  if(i<12.5)return 15.;if(i<13.5)return 7.;if(i<14.5)return 13.;return 5.;\n}\n\nfloat tinta(vec2 px,float v,float graus,float semente){\n  if(v<.005)return 0.;\n  if(v>.995)return 1.;\n  vec2 pos=girar(px,graus)/uCell;\n  vec2 cel=floor(pos);\n  vec2 f=fract(pos)-.5;\n  float g;\n  if(uModo<.5){\n    // grão de riso: limiar por célula com ruído médio, ponto redondo\n    float n=hash(cel+semente);\n    float med=hash(floor(pos*.2)+semente+43.);\n    float limiar=1.-clamp(v*(.85+.3*med),0.,1.);\n    float suave=mix(.1,.03,v);\n    g=smoothstep(limiar-suave,limiar+suave,n);\n    g*=1.-smoothstep(.85-.08,.85+.08,length(f)*2.);\n  }else if(uModo<1.5){\n    float r=sqrt(v)*.56;\n    g=1.-smoothstep(r-.04,r+.04,length(f));\n  }else{\n    vec2 b=floor(girar(px,graus)/(uCell*.5));\n    g=step((bayer4(b)+.5)/16.,v);\n  }\n  if(hash(cel+semente+200.)<.03)g=0.;\n  return g;\n}\n\nvoid main(){\n  vec2 px=vec2(gl_FragCoord.x,uRes.y-gl_FragCoord.y);\n\n  float grao=hash(px*.4)*.5+hash(px*.2+7.3)*.3+hash(px*.08+13.7)*.2;\n  vec3 c=uPapel+(grao-.5)*uRuido*.08;\n  float absorve=1.-.06*(hash(px*.15+31.)-.5);\n\n  vec2 d=px-uPonteiro.xy;\n  float perto=(1.-smoothstep(0.,uRaio,length(d)))*uPonteiro.z;\n  vec2 puxa=d*perto*.35;\n  vec2 o0=vec2(.6,-.4)*uMis+puxa;\n  vec2 o1=vec2(-.5,.35)*uMis-puxa;\n  float v0=pow(cobertura(px+o0),.9);\n  float v1=smoothstep(.55,1.,cobertura(px+o1));\n\n  c=mix(c,uTinta0,tinta(px+o0,v0,15.,0.)*.94*absorve);\n  c=mix(c,uTinta1,tinta(px+o1,v1,75.,100.)*.88*absorve);\n  gl_FragColor=vec4(c,1.);\n}\n`;\n\n/** Paradas padrão: papel, tinta da marca e tinta escura. Tudo token. */\nconst CORES_PADRAO = [\n  \"var(--background)\",\n  \"var(--brand)\",\n  \"color-mix(in oklab, var(--brand) 30%, var(--foreground))\",\n];\n\nconst MODOS = { grain: 0, halftone: 1, bayer: 2 } as const;\n\nexport interface ShaderRisoProps extends Omit<React.HTMLAttributes<HTMLDivElement>, \"children\"> {\n  /** A imagem a imprimir. Fundo branco ou transparente vira papel. */\n  src?: string;\n  /**\n   * Fonte viva no lugar de `src`: um canvas ou vídeo que muda, relido a cada quadro.\n   * O alfa da fonte vira papel. Com `source`, o cursor é lido na janela inteira,\n   * então dá pra pôr o riso por cima de uma peça arrastável com `pointer-events: none`.\n   */\n  source?: HTMLCanvasElement | HTMLVideoElement | null;\n  /** Texto alternativo da imagem. */\n  alt?: string;\n  /** Como a tinta assenta: grão de riso, ponto de retícula ou Bayer. */\n  mode?: \"grain\" | \"halftone\" | \"bayer\";\n  /**\n   * Tamanho da célula de tinta, em px.\n   * @min 2\n   * @max 16\n   * @step 1\n   */\n  cell?: number;\n  /**\n   * Quanto as duas tintas saem do registro.\n   * @min 0\n   * @max 100\n   * @step 1\n   */\n  misregistration?: number;\n  /**\n   * Textura do papel.\n   * @min 0\n   * @max 100\n   * @step 1\n   */\n  paper?: number;\n  /** O cursor puxa a folha: perto dele as tintas escorregam do registro. */\n  interactive?: boolean;\n  /** Paradas: papel, primeira tinta e segunda tinta. Qualquer cor CSS ou token. */\n  colors?: string[];\n}\n\nfunction compilar(gl: WebGLRenderingContext, frag: string): WebGLProgram | null {\n  const shader = (tipo: number, src: string) => {\n    const s = gl.createShader(tipo);\n    if (!s) return null;\n    gl.shaderSource(s, src);\n    gl.compileShader(s);\n    return gl.getShaderParameter(s, gl.COMPILE_STATUS) ? s : null;\n  };\n  const vs = shader(gl.VERTEX_SHADER, VERT);\n  const fs = shader(gl.FRAGMENT_SHADER, frag);\n  const p = gl.createProgram();\n  if (!vs || !fs || !p) return null;\n  gl.attachShader(p, vs);\n  gl.attachShader(p, fs);\n  gl.linkProgram(p);\n  gl.deleteShader(vs);\n  gl.deleteShader(fs);\n  return gl.getProgramParameter(p, gl.LINK_STATUS) ? p : null;\n}\n\nexport function ShaderRiso({\n  src,\n  source,\n  alt = \"Imagem em risografia\",\n  mode = \"grain\",\n  cell = 4,\n  misregistration = 35,\n  paper = 40,\n  interactive = true,\n  colors,\n  className,\n  ...props\n}: ShaderRisoProps) {\n  const wrapRef = React.useRef<HTMLDivElement>(null);\n  const params = React.useRef({ mode, cell, misregistration, paper, interactive });\n  params.current = { mode, cell, misregistration, paper, interactive };\n  const redesenhar = React.useRef<() => void>(() => {});\n  const coresChave = (colors ?? CORES_PADRAO).join(\"|\");\n\n  React.useEffect(() => {\n    const wrap = wrapRef.current;\n    if (!wrap) return;\n    const canvas = document.createElement(\"canvas\");\n    canvas.setAttribute(\"aria-hidden\", \"true\");\n    canvas.className = \"absolute inset-0 block h-full w-full\";\n    wrap.appendChild(canvas);\n    const gl = canvas.getContext(\"webgl\", { antialias: false, alpha: false });\n    const prog = gl && compilar(gl, FRAG);\n    if (!gl || !prog) {\n      canvas.remove();\n      return;\n    }\n\n    const buf = gl.createBuffer();\n    gl.bindBuffer(gl.ARRAY_BUFFER, buf);\n    gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);\n    gl.useProgram(prog);\n    const aPos = gl.getAttribLocation(prog, \"a_pos\");\n    gl.enableVertexAttribArray(aPos);\n    gl.vertexAttribPointer(aPos, 2, gl.FLOAT, false, 0, 0);\n    const u = (n: string) => gl.getUniformLocation(prog, n);\n    const U = {\n      res: u(\"uRes\"),\n      img: u(\"uImg\"),\n      tex: u(\"uTex\"),\n      cell: u(\"uCell\"),\n      mis: u(\"uMis\"),\n      modo: u(\"uModo\"),\n      ruido: u(\"uRuido\"),\n      ponteiro: u(\"uPonteiro\"),\n      raio: u(\"uRaio\"),\n      papel: u(\"uPapel\"),\n      tinta0: u(\"uTinta0\"),\n      tinta1: u(\"uTinta1\"),\n    };\n\n    const tex = gl.createTexture();\n    gl.bindTexture(gl.TEXTURE_2D, tex);\n    // 1×1 transparente até a imagem chegar: o primeiro quadro é só papel.\n    gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 1, 1, 0, gl.RGBA, gl.UNSIGNED_BYTE, new Uint8Array(4));\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    gl.uniform1i(U.tex, 0);\n    gl.uniform2f(U.img, 1, 1);\n\n    const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n    let dpr = 1;\n    let raf = 0;\n    const ponta = { x: 0, y: 0, forca: 0, alvo: 0 };\n\n    const cores = () => {\n      const [a, b, c] = coresChave.split(\"|\").map((cor) => resolveCssColor(cor, wrap));\n      gl.uniform3fv(U.papel, a);\n      gl.uniform3fv(U.tinta0, b ?? a);\n      gl.uniform3fv(U.tinta1, c ?? b ?? a);\n    };\n\n    const medir = () => {\n      // offsetWidth é a medida local: a vitrine escala o palco com transform.\n      dpr = Math.min(2, window.devicePixelRatio || 1);\n      canvas.width = Math.max(1, Math.round(wrap.offsetWidth * dpr));\n      canvas.height = Math.max(1, Math.round(wrap.offsetHeight * dpr));\n      gl.viewport(0, 0, canvas.width, canvas.height);\n    };\n\n    const desenhar = () => {\n      const p = params.current;\n      gl.uniform2f(U.res, canvas.width, canvas.height);\n      gl.uniform1f(U.cell, Math.max(2, Math.min(16, p.cell)) * dpr);\n      gl.uniform1f(U.mis, (p.misregistration / 100) * 8 * dpr);\n      gl.uniform1f(U.modo, MODOS[p.mode] ?? 0);\n      gl.uniform1f(U.ruido, p.paper / 100);\n      gl.uniform3f(U.ponteiro, ponta.x, ponta.y, p.interactive ? ponta.forca : 0);\n      gl.uniform1f(U.raio, Math.min(canvas.width, canvas.height) * 0.3);\n      gl.drawArrays(gl.TRIANGLES, 0, 3);\n    };\n\n    // O laço só vive enquanto o puxão está entrando ou saindo.\n    const quadro = () => {\n      ponta.forca += (ponta.alvo - ponta.forca) * 0.18;\n      if (Math.abs(ponta.alvo - ponta.forca) < 0.005) ponta.forca = ponta.alvo;\n      desenhar();\n      raf = ponta.forca !== ponta.alvo ? requestAnimationFrame(quadro) : 0;\n    };\n    const pedirQuadro = () => {\n      if (rafFonte) return; // o laço da fonte já desenha todo quadro\n      if (mq.matches) {\n        ponta.forca = ponta.alvo;\n        desenhar();\n      } else if (!raf) raf = requestAnimationFrame(quadro);\n    };\n\n    redesenhar.current = () => {\n      if (!raf) desenhar();\n    };\n\n    const mover = (e: PointerEvent) => {\n      // Ponteiro em px locais: a vitrine escala o palco com transform.\n      const r = wrap.getBoundingClientRect();\n      const k = r.width ? wrap.offsetWidth / r.width : 1;\n      ponta.x = (e.clientX - r.left) * k * dpr;\n      ponta.y = (e.clientY - r.top) * k * dpr;\n      ponta.alvo = 1;\n      if (raf) return;\n      if (ponta.forca === ponta.alvo) desenhar();\n      else pedirQuadro();\n    };\n    const sair = () => {\n      ponta.alvo = 0;\n      pedirQuadro();\n    };\n\n    let vivo = true;\n    let rafFonte = 0;\n    const img = new Image();\n    img.crossOrigin = \"anonymous\";\n    img.decoding = \"async\";\n    img.onload = () => {\n      if (!vivo) return;\n      gl.bindTexture(gl.TEXTURE_2D, tex);\n      gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, img);\n      gl.uniform2f(U.img, img.naturalWidth || 1, img.naturalHeight || 1);\n      desenhar();\n    };\n    if (src && !source) img.src = src;\n\n    cores();\n    medir();\n    desenhar();\n\n    const ro = new ResizeObserver(() => {\n      medir();\n      desenhar();\n    });\n    ro.observe(wrap);\n    const offTema = onThemeChange(() => {\n      cores();\n      desenhar();\n    });\n    // Com fonte viva, o riso costuma ficar por cima de algo arrastável e com\n    // `pointer-events: none`: o cursor é lido na janela e vale dentro do quadro.\n    const moverJanela = (e: PointerEvent) => {\n      const r = wrap.getBoundingClientRect();\n      const dentro =\n        e.clientX >= r.left && e.clientX <= r.right && e.clientY >= r.top && e.clientY <= r.bottom;\n      if (dentro) mover(e);\n      else if (ponta.alvo) sair();\n      if (source && mq.matches) subir();\n    };\n    if (source) window.addEventListener(\"pointermove\", moverJanela);\n    else {\n      wrap.addEventListener(\"pointermove\", mover);\n      wrap.addEventListener(\"pointerleave\", sair);\n    }\n\n    // ── fonte viva ──────────────────────────────────────────────────────────\n    const larguraFonte = () =>\n      source instanceof HTMLVideoElement ? source.videoWidth : (source?.width ?? 0);\n    const alturaFonte = () =>\n      source instanceof HTMLVideoElement ? source.videoHeight : (source?.height ?? 0);\n    const subir = () => {\n      if (!source) return;\n      const w = larguraFonte();\n      const h = alturaFonte();\n      if (!w || !h) return;\n      gl.bindTexture(gl.TEXTURE_2D, tex);\n      try {\n        gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, source);\n      } catch {\n        return; // fonte de outra origem sem CORS: fica o último quadro bom\n      }\n      gl.uniform2f(U.img, w, h);\n      desenhar();\n    };\n    let naTela = false;\n    const laco = () => {\n      // O puxão do cursor tem laço próprio; aqui só a textura, e o desenho junto.\n      ponta.forca += (ponta.alvo - ponta.forca) * 0.18;\n      if (Math.abs(ponta.alvo - ponta.forca) < 0.005) ponta.forca = ponta.alvo;\n      subir();\n      rafFonte = requestAnimationFrame(laco);\n    };\n    const ligarFonte = () => {\n      const deve = !!source && naTela && !document.hidden && !mq.matches;\n      if (deve && !rafFonte) {\n        cancelAnimationFrame(raf);\n        raf = 0;\n        rafFonte = requestAnimationFrame(laco);\n      } else if (!deve && rafFonte) {\n        cancelAnimationFrame(rafFonte);\n        rafFonte = 0;\n        subir();\n      }\n    };\n    const io = new IntersectionObserver(([e]) => {\n      naTela = e.isIntersecting;\n      ligarFonte();\n    });\n    if (source) {\n      io.observe(wrap);\n      document.addEventListener(\"visibilitychange\", ligarFonte);\n      mq.addEventListener?.(\"change\", ligarFonte);\n      subir();\n    }\n\n    return () => {\n      vivo = false;\n      img.onload = null;\n      ro.disconnect();\n      offTema();\n      cancelAnimationFrame(raf);\n      cancelAnimationFrame(rafFonte);\n      io.disconnect();\n      document.removeEventListener(\"visibilitychange\", ligarFonte);\n      mq.removeEventListener?.(\"change\", ligarFonte);\n      window.removeEventListener(\"pointermove\", moverJanela);\n      wrap.removeEventListener(\"pointermove\", mover);\n      wrap.removeEventListener(\"pointerleave\", sair);\n      redesenhar.current = () => {};\n      gl.deleteTexture(tex);\n      gl.deleteBuffer(buf);\n      gl.deleteProgram(prog);\n      gl.getExtension(\"WEBGL_lose_context\")?.loseContext();\n      canvas.remove();\n    };\n  }, [src, source, coresChave]);\n\n  React.useEffect(() => {\n    redesenhar.current();\n  }, [mode, cell, misregistration, paper, interactive]);\n\n  return (\n    <div\n      ref={wrapRef}\n      role=\"img\"\n      aria-label={alt}\n      className={cn(\"relative block aspect-square w-full overflow-hidden bg-background\", className)}\n      {...props}\n    />\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "meta": {
    "uso": {
      "quando": "Foto impressa em duas tintas de risografia com grão, em editorial, zine ou evento",
      "evite": "Uma tinta só com retícula: shader-halftone; caracteres, shader-ascii"
    }
  },
  "categories": [
    "shaders",
    "shader",
    "webgl",
    "imagem",
    "riso",
    "impressao"
  ],
  "type": "registry:ui"
}