{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "slug": "liquid-media",
  "name": "Liquid Media",
  "title": "Liquid Media",
  "type": "registry:ui",
  "description": "Interactive WebGL fluid ripple distortion effect for images and videos with responsive cursor displacement.",
  "summary": "An interactive WebGL media component that responds to cursor movement with fluid ripples and displacement. Features offscreen FBO sprite splats, damping, object-fit texture mapping, and automatic WebGL provider scoping.",
  "category": "galleries-and-media",
  "tags": [
    "liquid",
    "webgl",
    "threejs",
    "shaders",
    "image",
    "video",
    "distortion",
    "ripple",
    "interactive",
    "cursor"
  ],
  "dependencies": [
    "three",
    "@react-three/fiber",
    "@react-three/drei",
    "@react-three/postprocessing",
    "motion"
  ],
  "devDependencies": [
    "@types/three"
  ],
  "version": "1.0.0",
  "createdDate": "2026-10-01",
  "updatedDate": "2026-10-01",
  "interactive": true,
  "supportsColor": false,
  "highlights": [
    "High-performance Three.js shader pipeline with offscreen FBO displacement render target",
    "Sub-pixel cursor velocity tracking with dual-mode idle and active damping physics",
    "Automatic CSS object-fit calculation preserving aspect-ratio cover and contain cropping",
    "Teleported 3D plane synchronization maintaining pixel-perfect DOM alignment on scroll and resize",
    "Supports images and videos (videos default to autoplay, muted, loop and playsInline)"
  ],
  "anatomy": [
    "<div> (WebglProvider wrapper, position: relative, hosts the canvas when no provider exists)",
    "<img> / <video> (Native Media Elements: sets aspect ratio, sizing flow, and accessible fallback)",
    "<Canvas> (React Three Fiber Canvas: manages WebGL viewport, frame synchronization, and render loop)",
    "<shaderMaterial> (Custom GLSL Displacement Shader: computes trigonometric UV deflection from FBO)"
  ],
  "physics": {
    "engine": "Three.js GLSL Fragment Shader & Offscreen FBO",
    "description": "Up to 100 brush sprites are rendered additively into an offscreen half-float buffer. The shader offsets UVs in a direction derived from the buffer value, scaled by damped cursor speed.",
    "parameters": [
      {
        "label": "Rotation Speed",
        "value": "0.2 rad/sec"
      },
      {
        "label": "Initial Opacity",
        "value": "0.22"
      },
      {
        "label": "Displacement Damping",
        "value": "6.3"
      },
      {
        "label": "Velocity Damping",
        "value": "6.3"
      }
    ]
  },
  "accessibility": {
    "aria": "Renders a native <img> with alt text, or a native <video> (pass aria-label yourself). The DOM element stays in place at opacity 0 under the canvas.",
    "keyboard": [
      {
        "key": "None",
        "description": "Not interactive. The effect is pointer-only."
      }
    ],
    "reducedMotion": "With prefers-reduced-motion, the ripple effect is disabled and the media renders undistorted. Set webglEnabled={false} to turn the effect off explicitly."
  },
  "props": [
    {
      "name": "rippleMap",
      "type": "Texture",
      "description": "Custom three.js brush texture for the ripples. Defaults to the built-in brush."
    },
    {
      "name": "src",
      "type": "string",
      "description": "Source path or URL of the image or video texture to displace.",
      "required": true
    },
    {
      "name": "alt",
      "type": "string",
      "description": "Alternative text for the image. Required when type is \"image\"."
    },
    {
      "name": "containerClassName",
      "type": "string",
      "description": "Classes for the WebGL provider wrapper created when LiquidMedia is used without a WebglProvider."
    },
    {
      "name": "type",
      "type": "\"image\" | \"video\"",
      "description": "Media type discriminator for choosing image or video texture pipeline.",
      "default": "'image'",
      "defaultValue": "\"image\""
    },
    {
      "name": "intensity",
      "type": "number",
      "description": "Strength of the liquid displacement distortion waves.",
      "default": "0.2",
      "defaultValue": "0.2"
    },
    {
      "name": "radius",
      "type": "number",
      "description": "Radius of the ripple splats relative to viewport dimensions.",
      "default": "12",
      "defaultValue": "12"
    },
    {
      "name": "expandRate",
      "type": "number",
      "description": "Rate at which ripple rings expand outward over time.",
      "default": "11",
      "defaultValue": "11"
    },
    {
      "name": "decayRate",
      "type": "number",
      "description": "Exponential fade decay rate of active ripples.",
      "default": "3",
      "defaultValue": "3"
    },
    {
      "name": "segments",
      "type": "number",
      "defaultValue": "1",
      "description": "Plane mesh subdivisions used for the media quad."
    },
    {
      "name": "webglEnabled",
      "type": "boolean",
      "description": "Toggle to enable WebGL shader displacement or fallback to native DOM media.",
      "default": "true",
      "defaultValue": "true"
    }
  ],
  "usage": "import { LiquidMedia } from \"@/components/ui/liquid-media\";\n\nexport function LiquidMediaDemo() {\n  return (\n    <div className=\"relative aspect-video w-full\">\n      <LiquidMedia\n        src=\"/images/portrait.jpg\"\n        alt=\"Editorial portrait\"\n        className=\"h-full w-full object-cover\"\n      />\n    </div>\n  );\n}",
  "files": [
    {
      "name": "liquid-media.tsx",
      "path": "src/registry/ui/liquid-media.tsx",
      "type": "registry:ui",
      "target": "@ui/liquid-media.tsx",
      "code": "\"use client\";\n\nimport React, {\n  type ComponentRef,\n  type ReactNode,\n  type RefObject,\n  Suspense,\n  useEffect,\n  useId,\n  useLayoutEffect,\n  useMemo,\n  useRef,\n  useState,\n  useSyncExternalStore,\n} from \"react\";\nimport {\n  AdditiveBlending,\n  type BufferAttribute,\n  type Camera,\n  type Group,\n  type InterleavedBufferAttribute,\n  LinearFilter,\n  MathUtils,\n  type Mesh,\n  type MeshBasicMaterial,\n  OrthographicCamera,\n  type PlaneGeometry,\n  SRGBColorSpace,\n  Scene,\n  type ShaderMaterial,\n  type Texture,\n  Vector2,\n  VideoTexture,\n} from \"three\";\nimport {\n  advance,\n  Canvas,\n  type CanvasProps,\n  createPortal,\n  useFrame,\n  useStore,\n  useThree,\n} from \"@react-three/fiber\";\nimport { useFBO, useTexture } from \"@react-three/drei\";\nimport { EffectComposer } from \"@react-three/postprocessing\";\nimport { cancelFrame, type FrameData, frame } from \"motion\";\nimport { cn } from \"@/lib/utils\";\n\nconst useIsoLayoutEffect =\n  typeof window !== \"undefined\" ? useLayoutEffect : useEffect;\n\nexport type ObjectFitCrop = {\n  repeatU: number;\n  repeatV: number;\n  fitScaleX: number;\n  fitScaleY: number;\n};\n\nexport function computeObjectFit(\n  planeAspect: number,\n  mediaAspect: number,\n  objectFit: string,\n): ObjectFitCrop {\n  const crop: ObjectFitCrop = {\n    repeatU: 1,\n    repeatV: 1,\n    fitScaleX: 1,\n    fitScaleY: 1,\n  };\n  if (!Number.isFinite(mediaAspect) || mediaAspect <= 0) return crop;\n\n  if (objectFit === \"cover\") {\n    if (planeAspect > mediaAspect) {\n      crop.repeatV = mediaAspect / planeAspect;\n    } else {\n      crop.repeatU = planeAspect / mediaAspect;\n    }\n  } else if (objectFit === \"contain\") {\n    if (planeAspect > mediaAspect) {\n      crop.fitScaleX = mediaAspect / planeAspect;\n    } else {\n      crop.fitScaleY = planeAspect / mediaAspect;\n    }\n  }\n\n  return crop;\n}\n\nexport function applyUvCrop(\n  uvAttribute: BufferAttribute | InterleavedBufferAttribute,\n  segments: number,\n  repeatU: number,\n  repeatV: number,\n) {\n  const offsetU = (1 - repeatU) / 2;\n  const offsetV = (1 - repeatV) / 2;\n\n  for (let iy = 0; iy <= segments; iy++) {\n    for (let ix = 0; ix <= segments; ix++) {\n      const index = iy * (segments + 1) + ix;\n      const u = ix / segments;\n      const v = 1 - iy / segments;\n      uvAttribute.setXY(index, u * repeatU + offsetU, v * repeatV + offsetV);\n    }\n  }\n\n  uvAttribute.needsUpdate = true;\n}\n\nexport type Pointer = {\n  uv: Vector2;\n  texUv: Vector2;\n  repeat: Vector2;\n  fit: Vector2;\n  hover: number;\n};\n\nexport type PointerTarget = Pointer | RefObject<Pointer>;\n\nexport function getPointerTarget(target: PointerTarget): Pointer {\n  if (\"current\" in target) {\n    return target.current;\n  }\n  return target;\n}\n\ntype UsePointerUvOptions = {\n  enabled: boolean;\n  getRect?: (el: HTMLElement) => DOMRect;\n};\n\nexport function usePointerUv(\n  el: RefObject<HTMLElement | null>,\n  { enabled, getRect }: UsePointerUvOptions,\n): RefObject<Pointer> {\n  const pointerRef = useRef<Pointer>({\n    uv: new Vector2(0.5, 0.5),\n    texUv: new Vector2(0.5, 0.5),\n    repeat: new Vector2(1, 1),\n    fit: new Vector2(1, 1),\n    hover: 0,\n  });\n\n  useEffect(() => {\n    if (!enabled) return;\n    const target = el.current;\n    if (!target) return;\n    const currentPointer = pointerRef.current;\n\n    const onMove = (event: PointerEvent) => {\n      const rect = getRect ? getRect(target) : target.getBoundingClientRect();\n      const width = rect.width || 1;\n      const height = rect.height || 1;\n      const x = (event.clientX - rect.left) / width;\n      const y = 1 - (event.clientY - rect.top) / height;\n      currentPointer.uv.set(x, y);\n      currentPointer.texUv.set(\n        x * currentPointer.fit.x + (1 - currentPointer.fit.x) / 2,\n        y * currentPointer.fit.y + (1 - currentPointer.fit.y) / 2,\n      );\n    };\n\n    const onEnter = () => {\n      currentPointer.hover = 1;\n    };\n    const onLeave = () => {\n      currentPointer.hover = 0;\n    };\n\n    target.addEventListener(\"pointermove\", onMove);\n    target.addEventListener(\"pointerenter\", onEnter);\n    target.addEventListener(\"pointerleave\", onLeave);\n\n    if (typeof target.matches === \"function\" && target.matches(\":hover\")) {\n      currentPointer.hover = 1;\n    }\n\n    return () => {\n      target.removeEventListener(\"pointermove\", onMove);\n      target.removeEventListener(\"pointerenter\", onEnter);\n      target.removeEventListener(\"pointerleave\", onLeave);\n    };\n  }, [enabled, el, getRect]);\n\n  return pointerRef;\n}\n\nfunction WebglTeleport() {\n  const items = new Map<string, ReactNode>();\n  const listeners = new Set<() => void>();\n  let snapshot: [string, ReactNode][] = [];\n\n  const emit = () => {\n    snapshot = Array.from(items.entries());\n    for (const listener of listeners) {\n      listener();\n    }\n  };\n\n  const subscribe = (listener: () => void) => {\n    listeners.add(listener);\n    return () => {\n      listeners.delete(listener);\n    };\n  };\n  const getSnapshot = () => snapshot;\n\n  function useItems() {\n    return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);\n  }\n\n  return {\n    In({ children }: { children: ReactNode }) {\n      const id = useId();\n\n      useIsoLayoutEffect(() => {\n        items.set(id, children);\n        emit();\n        return () => {\n          items.delete(id);\n          emit();\n        };\n      }, [id, children]);\n      return null;\n    },\n    useItems,\n    Out() {\n      const list = useItems();\n      return (\n        <>\n          {list.map(([id, node]) => (\n            <Suspense key={id} fallback={null}>\n              {node}\n            </Suspense>\n          ))}\n        </>\n      );\n    },\n  };\n}\n\nexport const webglTeleport = WebglTeleport();\nexport const effectTeleport = WebglTeleport();\n\nexport function WebglPortal() {\n  return <webglTeleport.Out />;\n}\n\nconst WebglContext = React.createContext<boolean>(false);\n\ntype WebglProviderProps = Omit<CanvasProps, \"children\" | \"eventSource\"> & {\n  children: ReactNode;\n  className?: string;\n  contained?: boolean;\n};\n\ntype WebglReadyOptions = {\n  scene?: Scene;\n  camera?: Camera;\n  enabled?: boolean;\n  onReady?: () => void;\n};\n\nexport function useWebglReady({\n  scene,\n  camera,\n  enabled = true,\n  onReady,\n}: WebglReadyOptions = {}) {\n  const [ready, setReady] = useState(false);\n  const gl = useThree((state) => state.gl);\n  const defaultScene = useThree((state) => state.scene);\n  const defaultCamera = useThree((state) => state.camera);\n  const onReadyRef = useRef(onReady);\n\n  useEffect(() => {\n    onReadyRef.current = onReady;\n  }, [onReady]);\n\n  const targetScene = scene ?? defaultScene;\n  const targetCamera = camera ?? defaultCamera;\n\n  useEffect(() => {\n    if (!enabled) return;\n    let active = true;\n\n    gl.compileAsync(targetScene, targetCamera).then(() => {\n      if (!active) return;\n      requestAnimationFrame(() => {\n        if (!active) return;\n        setReady(true);\n        onReadyRef.current?.();\n      });\n    });\n\n    return () => {\n      active = false;\n    };\n  }, [gl, targetScene, targetCamera, enabled]);\n\n  return ready;\n}\n\ntype CanvasStore = ReturnType<typeof useStore>;\nconst canvasStores = new Set<CanvasStore>();\nlet clockStart: number | null = null;\n\nfunction tick(data: FrameData) {\n  if (clockStart === null) clockStart = data.timestamp;\n\n  const elapsed = (data.timestamp - clockStart) / 1000;\n\n  let runGlobalEffects = true;\n  for (const store of canvasStores) {\n    const state = store.getState();\n    if (state.internal.active) {\n      advance(elapsed, runGlobalEffects, state);\n      runGlobalEffects = false;\n    }\n  }\n}\n\nfunction MotionFrameloop() {\n  const store = useStore();\n\n  useEffect(() => {\n    canvasStores.add(store);\n    if (canvasStores.size === 1) frame.postRender(tick, true);\n    return () => {\n      canvasStores.delete(store);\n      if (canvasStores.size === 0) cancelFrame(tick);\n    };\n  }, [store]);\n\n  return null;\n}\n\nfunction RootRender() {\n  const gl = useThree((state) => state.gl);\n  const scene = useThree((state) => state.scene);\n  const camera = useThree((state) => state.camera);\n\n  useFrame(() => gl.render(scene, camera), 1);\n\n  return null;\n}\n\nfunction Effects() {\n  const effects = effectTeleport.useItems();\n  const gl = useThree((state) => state.gl);\n  const mounted = effects.length > 0;\n\n  useEffect(() => {\n    if (!mounted) return;\n    return () => {\n      gl.autoClear = true;\n    };\n  }, [mounted, gl]);\n\n  if (!mounted) return <RootRender />;\n\n  return (\n    <EffectComposer key={effects.length} multisampling={0}>\n      <effectTeleport.Out />\n    </EffectComposer>\n  );\n}\n\nexport function WebglProvider({\n  children,\n  className,\n  style,\n  contained = false,\n  ...canvasProps\n}: WebglProviderProps) {\n  const [eventSource, setEventSource] = useState<ComponentRef<\"div\"> | null>(\n    null,\n  );\n\n  return (\n    <WebglContext.Provider value={true}>\n      <div\n        ref={setEventSource}\n        data-atelier-webgl=\"\"\n        className={className}\n        style={contained ? { position: \"relative\" } : { display: \"contents\" }}\n      >\n        <Canvas\n          eventPrefix=\"client\"\n          dpr={[1, 1.8]}\n          {...canvasProps}\n          frameloop=\"never\"\n          eventSource={eventSource ?? undefined}\n          style={{\n            position: contained ? \"absolute\" : \"fixed\",\n            inset: 0,\n            pointerEvents: \"none\",\n            ...style,\n          }}\n        >\n          <MotionFrameloop />\n          <WebglPortal />\n          <Effects />\n        </Canvas>\n\n        {children}\n      </div>\n    </WebglContext.Provider>\n  );\n}\n\ntype WebglImageProps = {\n  src: string;\n  alt: string;\n  material?: (map: Texture, pointer: PointerTarget) => React.ReactNode;\n  webglEnabled?: boolean;\n  segments?: number;\n  zIndex?: number;\n  autoReflow?: boolean;\n} & Omit<React.ComponentPropsWithoutRef<\"img\">, \"children\" | \"src\" | \"alt\">;\n\ntype ImagePlaneProps = {\n  el: RefObject<HTMLImageElement | null>;\n  src: string;\n  segments: number;\n  material?: (map: Texture, pointer: PointerTarget) => React.ReactNode;\n  pointer: PointerTarget;\n  zIndex: number;\n  autoReflow: boolean;\n};\n\nfunction ImagePlane({\n  el,\n  src,\n  segments,\n  material,\n  pointer,\n  zIndex,\n  autoReflow,\n}: ImagePlaneProps) {\n  const mesh = useRef<Mesh>(null);\n  const texture = useTexture(src);\n  const fitScale = useRef({ x: 1, y: 1 });\n  const size = useThree((state) => state.size);\n  const viewport = useThree((state) => state.viewport);\n  const gl = useThree((state) => state.gl);\n\n  useLayoutEffect(() => {\n    const target = el.current;\n    if (!target) return;\n\n    const measure = () => {\n      const m = mesh.current;\n      if (!m) return;\n      const rect = target.getBoundingClientRect();\n      if (!rect || rect.height <= 0) return;\n\n      const image = texture.image as HTMLImageElement;\n      if (!image || !image.height) return;\n\n      const crop = computeObjectFit(\n        rect.width / rect.height,\n        image.width / image.height,\n        getComputedStyle(target).objectFit,\n      );\n\n      fitScale.current.x = crop.fitScaleX;\n      fitScale.current.y = crop.fitScaleY;\n      const p = getPointerTarget(pointer);\n      p.repeat.set(crop.repeatU, crop.repeatV);\n      p.fit.set(\n        crop.repeatU / crop.fitScaleX,\n        crop.repeatV / crop.fitScaleY,\n      );\n\n      applyUvCrop(\n        m.geometry.attributes.uv,\n        segments,\n        crop.repeatU,\n        crop.repeatV,\n      );\n    };\n\n    measure();\n\n    const ro = new ResizeObserver(measure);\n    ro.observe(target);\n    ro.observe(document.body);\n    return () => ro.disconnect();\n  }, [el, texture, segments, pointer]);\n\n  useFrame(() => {\n    const m = mesh.current;\n    if (!m || !el.current || size.height <= 0) return;\n    const pxToWorld = viewport.height / size.height;\n    const fit = fitScale.current;\n\n    const canvasRect =\n      gl.domElement && typeof gl.domElement.getBoundingClientRect === \"function\"\n        ? gl.domElement.getBoundingClientRect()\n        : { left: 0, top: 0 };\n\n    if (autoReflow && el.current) {\n      const rect = el.current.getBoundingClientRect();\n      const relLeft = rect.left - canvasRect.left;\n      const relTop = rect.top - canvasRect.top;\n\n      m.position.x = (relLeft + rect.width / 2 - size.width / 2) * pxToWorld;\n      m.position.y = -(relTop + rect.height / 2 - size.height / 2) * pxToWorld;\n      m.scale.x = rect.width * pxToWorld * fit.x;\n      m.scale.y = rect.height * pxToWorld * fit.y;\n    }\n  });\n\n  return (\n    <mesh ref={mesh} renderOrder={zIndex}>\n      <planeGeometry args={[1, 1, segments, segments]} />\n      {material ? (\n        material(texture, pointer)\n      ) : (\n        <meshBasicMaterial map={texture} transparent />\n      )}\n    </mesh>\n  );\n}\n\nexport function WebglImage({\n  src,\n  alt,\n  className,\n  style,\n  material,\n  webglEnabled = true,\n  segments = 1,\n  zIndex = 0,\n  autoReflow = true,\n  ...rest\n}: WebglImageProps) {\n  const el = useRef<ComponentRef<\"img\">>(null);\n  const pointer = usePointerUv(el, { enabled: webglEnabled });\n\n  return (\n    <>\n      <img\n        ref={el}\n        src={src}\n        alt={alt}\n        className={className}\n        style={webglEnabled ? { ...style, opacity: 0 } : style}\n        {...rest}\n      />\n\n      {webglEnabled && (\n        <webglTeleport.In>\n          <ImagePlane\n            el={el}\n            src={src}\n            segments={segments}\n            material={material}\n            pointer={pointer}\n            zIndex={zIndex}\n            autoReflow={autoReflow}\n          />\n        </webglTeleport.In>\n      )}\n    </>\n  );\n}\n\ntype WebglVideoProps = {\n  src: string;\n  material?: (map: Texture, pointer: PointerTarget) => React.ReactNode;\n  webglEnabled?: boolean;\n  segments?: number;\n  zIndex?: number;\n  autoReflow?: boolean;\n} & Omit<React.ComponentPropsWithoutRef<\"video\">, \"children\" | \"src\">;\n\ntype VideoPlaneProps = {\n  el: RefObject<HTMLVideoElement | null>;\n  segments: number;\n  material?: (map: Texture, pointer: PointerTarget) => React.ReactNode;\n  pointer: PointerTarget;\n  zIndex: number;\n  autoReflow: boolean;\n};\n\nfunction VideoPlane({\n  el,\n  segments,\n  material,\n  pointer,\n  zIndex,\n  autoReflow,\n}: VideoPlaneProps) {\n  const mesh = useRef<Mesh>(null);\n  const [texture, setTexture] = useState<VideoTexture | null>(null);\n  const fitScale = useRef({ x: 1, y: 1 });\n  const size = useThree((state) => state.size);\n  const viewport = useThree((state) => state.viewport);\n  const gl = useThree((state) => state.gl);\n\n  useEffect(() => {\n    const video = el.current;\n    if (!video) return;\n\n    const videoTexture = new VideoTexture(video);\n    videoTexture.colorSpace = SRGBColorSpace;\n    queueMicrotask(() => {\n      setTexture(videoTexture);\n    });\n    return () => videoTexture.dispose();\n  }, [el]);\n\n  useLayoutEffect(() => {\n    const target = el.current;\n    if (!target || !texture) return;\n\n    const measure = () => {\n      const m = mesh.current;\n      if (!m) return;\n      const rect = target.getBoundingClientRect();\n      if (!rect || rect.height <= 0) return;\n\n      const video = texture.image as HTMLVideoElement;\n      if (!video || !video.videoHeight) return;\n\n      const crop = computeObjectFit(\n        rect.width / rect.height,\n        video.videoWidth / video.videoHeight,\n        getComputedStyle(target).objectFit,\n      );\n\n      fitScale.current.x = crop.fitScaleX;\n      fitScale.current.y = crop.fitScaleY;\n      const p = getPointerTarget(pointer);\n      p.repeat.set(crop.repeatU, crop.repeatV);\n      p.fit.set(\n        crop.repeatU / crop.fitScaleX,\n        crop.repeatV / crop.fitScaleY,\n      );\n\n      applyUvCrop(\n        m.geometry.attributes.uv,\n        segments,\n        crop.repeatU,\n        crop.repeatV,\n      );\n    };\n\n    measure();\n\n    target.addEventListener(\"loadedmetadata\", measure);\n    target.addEventListener(\"resize\", measure);\n\n    const ro = new ResizeObserver(measure);\n    ro.observe(target);\n    ro.observe(document.body);\n    return () => {\n      ro.disconnect();\n      target.removeEventListener(\"loadedmetadata\", measure);\n      target.removeEventListener(\"resize\", measure);\n    };\n  }, [el, texture, segments, pointer]);\n\n  useFrame(() => {\n    texture?.update();\n    const m = mesh.current;\n    if (!m || !el.current || size.height <= 0) return;\n    const pxToWorld = viewport.height / size.height;\n    const fit = fitScale.current;\n\n    const canvasRect =\n      gl.domElement && typeof gl.domElement.getBoundingClientRect === \"function\"\n        ? gl.domElement.getBoundingClientRect()\n        : { left: 0, top: 0 };\n\n    if (autoReflow && el.current) {\n      const rect = el.current.getBoundingClientRect();\n      const relLeft = rect.left - canvasRect.left;\n      const relTop = rect.top - canvasRect.top;\n\n      m.position.x = (relLeft + rect.width / 2 - size.width / 2) * pxToWorld;\n      m.position.y = -(relTop + rect.height / 2 - size.height / 2) * pxToWorld;\n      m.scale.x = rect.width * pxToWorld * fit.x;\n      m.scale.y = rect.height * pxToWorld * fit.y;\n    }\n  });\n\n  if (!texture) return null;\n\n  return (\n    <mesh ref={mesh} renderOrder={zIndex}>\n      <planeGeometry args={[1, 1, segments, segments]} />\n      {material ? (\n        material(texture, pointer)\n      ) : (\n        <meshBasicMaterial map={texture} transparent />\n      )}\n    </mesh>\n  );\n}\n\nexport function WebglVideo({\n  src,\n  className,\n  style,\n  material,\n  webglEnabled = true,\n  segments = 1,\n  zIndex = 0,\n  autoReflow = true,\n  autoPlay = true,\n  muted = true,\n  loop = true,\n  playsInline = true,\n  ...rest\n}: WebglVideoProps) {\n  const el = useRef<ComponentRef<\"video\">>(null);\n  const pointer = usePointerUv(el, { enabled: webglEnabled });\n\n  return (\n    <>\n      <video\n        ref={el}\n        src={src}\n        className={className}\n        style={webglEnabled ? { ...style, opacity: 0 } : style}\n        autoPlay={autoPlay}\n        muted={muted}\n        loop={loop}\n        playsInline={playsInline}\n        {...rest}\n      />\n\n      {webglEnabled && (\n        <webglTeleport.In>\n          <VideoPlane\n            el={el}\n            segments={segments}\n            material={material}\n            pointer={pointer}\n            zIndex={zIndex}\n            autoReflow={autoReflow}\n          />\n        </webglTeleport.In>\n      )}\n    </>\n  );\n}\n\nconst ROTATION_SPEED = 0.1;\nconst INITIAL_OPACITY = 0.22;\nconst DISPLACEMENT_DAMPING = 6.3;\nconst VELOCITY_DAMPING = 6.3;\nconst IDLE_VELOCITY_DAMPING = 0.3;\nconst SPAWN_SPACING = 0.2;\nconst MIN_SPAWN_INTERVAL = 1 / 60;\nconst MIN_SPAWN_DISTANCE = 0.005;\nconst MIN_VISIBLE_OPACITY = 0.002;\n\ntype Splat = Mesh<PlaneGeometry, MeshBasicMaterial>;\n\nconst RIPPLE_BRUSH =\n  \"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAIAAAACACAMAAAD04JH5AAAAnFBMVEUAAAD////////////////////////////////////////////////////+/v7////9/f3////9/f39/f3////////////9/f3+/v79/f3////////+/v79/f3+/v7////////+/v7////+/v7////+/v7+/v7////////////+/v7////+/v7+/v7+/v7+/v7////////////////Clg1EAAAANHRSTlMACAwRBBUcICQZKDM4LEAwSjxGTml2XEOCUmRhh1eLcXpUbpl+Wo+1kqumsJafnKK+ucPKHu8hDAAADa9JREFUeNrslcl6olAUhJOWCCKDgDJeJkEEFBzy/u/Wda7na9PboK6sRRbJon7qVN18vPXWW2/9UspNH68Wm3/e9XoIcv9zFzG8EkJRyH42m31BM4gYXgjB9l+LxVxqISkY4gUM5I9vn6uqpum6rmmqCghmeAGC/HzYa7ppL0m2aeqaykGA4ZkI/PmzxVzT7aVhWUEQWJZjAAIMKjM8sZMc/0LVzaUTuJ4XRZHnrdzAMgwwAAIMHANTPMOfPt+wXC/1kySOE39XRh4zSAQeBpfysfbsv3Rcr/TjbU7arteJn0bIgW7BdfgxT+Wx54e/Cf9oF283RR1CdbEBRLJLcQvrXx1AwY14FMInn1/6l/46L0LRNG3bNFkYgmEdyxhwCbkLnucNQXnQ+rh+FvlvatFU4zgM41gdGxHWhJDsZBscAxTLWxZAeAiBwuvXTcNalcl6E2bH8dR3UH8axqrNgLBFCrvSW61cl/dpm5q6YIKp/jOuP/x96T/03WEPHQ5gGCmFAghIYZemZVqWkeyErTPBg+bH/jX5H/aXy/l8uYCh6zkE2sQ6juWPxC+9wFkSwWSA//3zWrRj35E/BAQQUAYZLeKmoqBtxH7kWiD4mkiAAPD8qtI/8mP0vzkOHQI4XyEQUAQDIhAYZU3bFBkk0IokXTm2PieCCQB8AOq/R/sPRVv1EuB88+cbNEQghGjaIwYyHltR53HpGrY27QgKv/94/j0MIC9oAQTwswOD9CdlbTXQPuh3WZ0nUWDQEfAeTQ3AsPgBDMlDEtxXQAcQMvxG9nNPXH2VFVt/5XAEEwOwjQANlAAwGQf6SJh3PduHNZpX42/Vqduf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\";\n\nconst vertexShader = `\nvarying vec2 vUv;\nvarying vec2 vScreenUv;\n\nvoid main() {\n    vUv = uv;\n    vec4 pos = projectionMatrix * modelViewMatrix * vec4(position, 1.0);\n    vScreenUv = pos.xy / pos.w * 0.5 + 0.5;\n    gl_Position = pos;\n}`;\n\nconst fragmentShader = `\nprecision highp float;\nuniform sampler2D uTexture;\nuniform sampler2D uDisplacement;\nuniform float uDisplacementIntensity;\nvarying vec2 vUv;\nvarying vec2 vScreenUv;\n\n#define PI 3.14159265\n\nvoid main() {\n    vec4 displacement = texture2D(uDisplacement, vScreenUv);\n    float theta = displacement.r * 2.0 * PI;\n    vec2 direction = vec2(sin(theta), cos(theta));\n    vec2 displacedUv = vUv + direction * displacement.r * uDisplacementIntensity;\n    vec4 color = texture2D(uTexture, displacedUv);\n    gl_FragColor = color;\n}`;\n\ntype LiquidMediaMaterialProps = {\n  map: Texture;\n  pointer: PointerTarget;\n} & Pick<\n  LiquidEffectProps,\n  \"rippleMap\" | \"intensity\" | \"radius\" | \"expandRate\" | \"decayRate\"\n>;\n\ntype Uniforms = {\n  uTexture: { value: Texture | null };\n  uDisplacement: { value: Texture | null };\n  uDisplacementIntensity: { value: number };\n};\n\nexport type LiquidEffectProps = {\n  rippleMap?: Texture;\n  intensity?: number;\n  radius?: number;\n  expandRate?: number;\n  decayRate?: number;\n  segments?: number;\n  webglEnabled?: boolean;\n};\n\ntype LiquidMediaImageProps = LiquidEffectProps & {\n  type?: \"image\";\n  src: string;\n  alt: string;\n} & Omit<React.ComponentPropsWithoutRef<\"img\">, \"src\" | \"alt\">;\n\ntype LiquidMediaVideoProps = LiquidEffectProps & {\n  type: \"video\";\n  src: string;\n} & Omit<React.ComponentPropsWithoutRef<\"video\">, \"src\">;\n\nexport type LiquidMediaProps = LiquidMediaImageProps | LiquidMediaVideoProps;\n\nfunction LiquidMediaMaterial({\n  map,\n  pointer,\n  rippleMap,\n  intensity = 0.2,\n  radius = 12,\n  expandRate = 11,\n  decayRate = 3,\n}: LiquidMediaMaterialProps) {\n  const { viewport, size, gl } = useThree();\n\n  const defaultBrush = useTexture(RIPPLE_BRUSH);\n  const brush = rippleMap ?? defaultBrush;\n  const anchorRef = useRef<Group>(null);\n  const splatIndex = useRef(0);\n  const spriteRefs = useRef<Splat[]>([]);\n  const spriteScene = useMemo(() => new Scene(), []);\n  const spriteCamera = useMemo(() => {\n    const cam = new OrthographicCamera(\n      -viewport.width / 2,\n      viewport.width / 2,\n      viewport.height / 2,\n      -viewport.height / 2,\n      0,\n      1,\n    );\n    cam.updateProjectionMatrix();\n    return cam;\n  }, [viewport.width, viewport.height]);\n\n  const materialRef = useRef<ShaderMaterial>(null);\n  const mouse = useRef({\n    x: 0,\n    y: 0,\n    velocity: 0,\n    spawnX: Infinity,\n    spawnY: Infinity,\n    spawnElapsed: Infinity,\n  });\n\n  const uniforms = useMemo<Uniforms>(\n    () => ({\n      uTexture: { value: map },\n      uDisplacement: { value: null },\n      uDisplacementIntensity: { value: 0 },\n    }),\n    [map],\n  );\n\n  const safeWidth = Math.max(1, size.width);\n  const safeHeight = Math.max(1, size.height);\n\n  const FBO = useFBO(safeWidth, safeHeight, {\n    minFilter: LinearFilter,\n    magFilter: LinearFilter,\n  });\n\n  useFrame((_, delta) => {\n    const parent = anchorRef.current?.parent as Mesh | null;\n    const mat = materialRef.current;\n    if (!parent || !mat || delta <= 0) return;\n\n    const sprites = spriteRefs.current;\n    const _mouse = mouse.current;\n    const p = getPointerTarget(pointer);\n    const hovering = p.hover > 0.5;\n\n    const pointerX = parent.position.x + (p.uv.x - 0.5) * parent.scale.x;\n    const pointerY = parent.position.y + (p.uv.y - 0.5) * parent.scale.y;\n\n    if (_mouse.spawnX === Infinity && hovering) {\n      _mouse.x = pointerX;\n      _mouse.y = pointerY;\n      _mouse.spawnX = pointerX;\n      _mouse.spawnY = pointerY;\n    }\n\n    const dx = pointerX - _mouse.x;\n    const dy = pointerY - _mouse.y;\n\n    const speed = Math.sqrt(dx * dx + dy * dy) / (delta * 60);\n\n    _mouse.x = pointerX;\n    _mouse.y = pointerY;\n\n    if (hovering) {\n      _mouse.velocity = MathUtils.damp(\n        _mouse.velocity,\n        speed,\n        VELOCITY_DAMPING,\n        delta,\n      );\n    } else {\n      _mouse.velocity = MathUtils.damp(\n        _mouse.velocity,\n        0,\n        IDLE_VELOCITY_DAMPING,\n        delta,\n      );\n    }\n\n    const scale = (radius * Math.min(viewport.width, viewport.height)) / 100;\n    const spacing = Math.max(scale * SPAWN_SPACING, MIN_SPAWN_DISTANCE);\n    const spawnDx = pointerX - _mouse.spawnX;\n    const spawnDy = pointerY - _mouse.spawnY;\n    const spawnDist = Math.sqrt(spawnDx * spawnDx + spawnDy * spawnDy);\n\n    _mouse.spawnElapsed += delta;\n\n    if (\n      hovering &&\n      spawnDist > spacing &&\n      _mouse.spawnElapsed > MIN_SPAWN_INTERVAL\n    ) {\n      const sprite = sprites[splatIndex.current];\n\n      if (sprite) {\n        sprite.visible = true;\n        sprite.position.set(pointerX, pointerY, 0);\n        sprite.scale.set(scale, scale, 1);\n        sprite.material.opacity = INITIAL_OPACITY;\n      }\n\n      splatIndex.current = (splatIndex.current + 1) % 100;\n      _mouse.spawnX = pointerX;\n      _mouse.spawnY = pointerY;\n      _mouse.spawnElapsed = 0;\n    }\n\n    for (const sprite of sprites) {\n      if (sprite.visible) {\n        sprite.rotation.z += 2 * delta * ROTATION_SPEED;\n        sprite.material.opacity = MathUtils.damp(\n          sprite.material.opacity,\n          0,\n          MathUtils.clamp(decayRate, 3, 10),\n          delta,\n        );\n        sprite.scale.x += delta * expandRate * scale;\n        sprite.scale.y = sprite.scale.x;\n        if (sprite.material.opacity < MIN_VISIBLE_OPACITY)\n          sprite.visible = false;\n      }\n    }\n\n    gl.setRenderTarget(FBO);\n    gl.render(spriteScene, spriteCamera);\n    gl.setRenderTarget(null);\n\n    mat.uniforms.uDisplacement.value = FBO.texture;\n    mat.uniforms.uDisplacementIntensity.value = MathUtils.damp(\n      mat.uniforms.uDisplacementIntensity.value,\n      intensity * _mouse.velocity * 5,\n      DISPLACEMENT_DAMPING,\n      delta,\n    );\n  });\n\n  const portal = useMemo(\n    () =>\n      createPortal(\n        <group>\n          {Array.from({ length: 100 }, (_, i) => (\n            <mesh\n              key={i}\n              ref={(mesh) => {\n                if (mesh) spriteRefs.current[i] = mesh as Splat;\n              }}\n              visible={false}\n              rotation-z={(i * 0.628318) % (Math.PI * 2)}\n            >\n              <planeGeometry args={[1, 1]} />\n              <meshBasicMaterial\n                map={brush}\n                transparent\n                blending={AdditiveBlending}\n                depthTest={false}\n                depthWrite={false}\n              />\n            </mesh>\n          ))}\n        </group>,\n        spriteScene,\n      ),\n    [brush, spriteScene],\n  );\n\n  return (\n    <>\n      <group ref={anchorRef} />\n\n      <shaderMaterial\n        ref={materialRef}\n        attach=\"material\"\n        vertexShader={vertexShader}\n        fragmentShader={fragmentShader}\n        uniforms={uniforms}\n      />\n\n      {portal}\n    </>\n  );\n}\n\nconst REDUCED_MOTION_QUERY = \"(prefers-reduced-motion: reduce)\";\n\nfunction subscribeReducedMotion(onChange: () => void) {\n  const media = window.matchMedia(REDUCED_MOTION_QUERY);\n  media.addEventListener(\"change\", onChange);\n  return () => media.removeEventListener(\"change\", onChange);\n}\n\nfunction getReducedMotion() {\n  return window.matchMedia(REDUCED_MOTION_QUERY).matches;\n}\n\nfunction LiquidMediaInner(props: LiquidMediaProps) {\n  const {\n    rippleMap,\n    intensity,\n    radius,\n    expandRate,\n    decayRate,\n    segments,\n    webglEnabled,\n    ...rest\n  } = props;\n\n  const material = (map: Texture, pointer: PointerTarget) => (\n    <LiquidMediaMaterial\n      map={map}\n      pointer={pointer}\n      rippleMap={rippleMap}\n      intensity={intensity}\n      radius={radius}\n      expandRate={expandRate}\n      decayRate={decayRate}\n    />\n  );\n\n  if (rest.type === \"video\") {\n    const { type: _type, ...videoProps } = rest;\n    return (\n      <WebglVideo\n        segments={segments}\n        webglEnabled={webglEnabled}\n        material={material}\n        {...videoProps}\n      />\n    );\n  }\n\n  const { type: _type, ...imageProps } = rest;\n  return (\n    <WebglImage\n      segments={segments}\n      webglEnabled={webglEnabled}\n      material={material}\n      {...imageProps}\n    />\n  );\n}\n\nexport function LiquidMedia(\n  props: LiquidMediaProps & { containerClassName?: string },\n) {\n  const hasProvider = React.useContext(WebglContext);\n  const reduceMotion = useSyncExternalStore(\n    subscribeReducedMotion,\n    getReducedMotion,\n    () => false,\n  );\n  const { containerClassName, ...mediaProps } = props;\n\n  const media = reduceMotion ? (\n    <LiquidMediaInner {...mediaProps} webglEnabled={false} />\n  ) : (\n    <LiquidMediaInner {...mediaProps} />\n  );\n\n  if (hasProvider) return media;\n\n  if (reduceMotion) {\n    return (\n      <div className={cn(\"relative w-full h-full\", containerClassName)}>\n        {media}\n      </div>\n    );\n  }\n\n  return (\n    <WebglProvider\n      contained\n      className={cn(\"relative w-full h-full\", containerClassName)}\n    >\n      {media}\n    </WebglProvider>\n  );\n}\n\nexport type LiquidMediaShowcaseProps = {\n  src?: string;\n  alt?: string;\n  caption?: string;\n  className?: string;\n  intensity?: number;\n  radius?: number;\n  expandRate?: number;\n  decayRate?: number;\n  segments?: number;\n  webglEnabled?: boolean;\n};\n\nexport function LiquidMediaShowcase({\n  src = \"/liquid-media-demo.jpg\",\n  alt = \"Editorial portrait\",\n  caption = \"Editorial Portrait\",\n  className,\n  intensity,\n  radius,\n  expandRate,\n  decayRate,\n  segments,\n  webglEnabled = true,\n}: LiquidMediaShowcaseProps) {\n  return (\n    <div\n      className={cn(\n        \"group relative w-full max-w-4xl overflow-hidden rounded-2xl border border-zinc-200/80 bg-zinc-950 shadow-2xl dark:border-white/10 select-none\",\n        className,\n      )}\n    >\n      <LiquidMedia\n        src={src}\n        alt={alt}\n        intensity={intensity}\n        radius={radius}\n        expandRate={expandRate}\n        decayRate={decayRate}\n        segments={segments}\n        webglEnabled={webglEnabled}\n        className=\"w-full h-auto aspect-16/10 object-cover\"\n      />\n      {caption ? (\n        <div className=\"pointer-events-none absolute inset-0 flex items-center justify-center p-4\">\n          <span className=\"font-serif text-lg sm:text-2xl md:text-3xl font-normal tracking-wide text-white/95 drop-shadow-[0_2px_12px_rgba(0,0,0,0.6)]\">\n            {caption}\n          </span>\n        </div>\n      ) : null}\n    </div>\n  );\n}\n\nexport type LiquidMediaPresetId =\n  | \"silk\"\n  | \"glass\"\n  | \"ripple\"\n  | \"tidal\"\n  | \"custom\";\n\nexport type LiquidMediaImageId = \"portrait\" | \"architecture\" | \"ocean\";\n\nexport interface LiquidMediaPreset {\n  id: LiquidMediaPresetId;\n  label: string;\n  intensity: number;\n  radius: number;\n  expandRate: number;\n  decayRate: number;\n}\n\nexport const LIQUID_MEDIA_PRESETS: readonly LiquidMediaPreset[] = [\n  {\n    id: \"silk\",\n    label: \"Silk Flow\",\n    intensity: 0.12,\n    radius: 9,\n    expandRate: 7,\n    decayRate: 2.2,\n  },\n  {\n    id: \"glass\",\n    label: \"Liquid Glass\",\n    intensity: 0.22,\n    radius: 12,\n    expandRate: 11,\n    decayRate: 3.0,\n  },\n  {\n    id: \"ripple\",\n    label: \"Deep Ripple\",\n    intensity: 0.42,\n    radius: 18,\n    expandRate: 16,\n    decayRate: 2.6,\n  },\n  {\n    id: \"tidal\",\n    label: \"Tidal Surge\",\n    intensity: 0.68,\n    radius: 24,\n    expandRate: 22,\n    decayRate: 3.4,\n  },\n] as const;\n\nexport const LIQUID_MEDIA_IMAGES: Record<\n  LiquidMediaImageId,\n  { label: string; src: string; caption: string }\n> = {\n  portrait: {\n    label: \"Portrait\",\n    src: \"/liquid-media-demo.jpg\",\n    caption: \"Editorial Portrait\",\n  },\n  architecture: {\n    label: \"Architecture\",\n    src: \"https://images.unsplash.com/photo-1600585154340-be6161a56a0c?w=1600&auto=format&fit=crop&q=80\",\n    caption: \"Modernist Villa\",\n  },\n  ocean: {\n    label: \"Ocean\",\n    src: \"https://images.unsplash.com/photo-1507525428034-b723cf961d3e?w=1600&auto=format&fit=crop&q=80\",\n    caption: \"Calm Horizon\",\n  },\n};\n\nexport default LiquidMedia;\n",
      "content": "\"use client\";\n\nimport React, {\n  type ComponentRef,\n  type ReactNode,\n  type RefObject,\n  Suspense,\n  useEffect,\n  useId,\n  useLayoutEffect,\n  useMemo,\n  useRef,\n  useState,\n  useSyncExternalStore,\n} from \"react\";\nimport {\n  AdditiveBlending,\n  type BufferAttribute,\n  type Camera,\n  type Group,\n  type InterleavedBufferAttribute,\n  LinearFilter,\n  MathUtils,\n  type Mesh,\n  type MeshBasicMaterial,\n  OrthographicCamera,\n  type PlaneGeometry,\n  SRGBColorSpace,\n  Scene,\n  type ShaderMaterial,\n  type Texture,\n  Vector2,\n  VideoTexture,\n} from \"three\";\nimport {\n  advance,\n  Canvas,\n  type CanvasProps,\n  createPortal,\n  useFrame,\n  useStore,\n  useThree,\n} from \"@react-three/fiber\";\nimport { useFBO, useTexture } from \"@react-three/drei\";\nimport { EffectComposer } from \"@react-three/postprocessing\";\nimport { cancelFrame, type FrameData, frame } from \"motion\";\nimport { cn } from \"@/lib/utils\";\n\nconst useIsoLayoutEffect =\n  typeof window !== \"undefined\" ? useLayoutEffect : useEffect;\n\nexport type ObjectFitCrop = {\n  repeatU: number;\n  repeatV: number;\n  fitScaleX: number;\n  fitScaleY: number;\n};\n\nexport function computeObjectFit(\n  planeAspect: number,\n  mediaAspect: number,\n  objectFit: string,\n): ObjectFitCrop {\n  const crop: ObjectFitCrop = {\n    repeatU: 1,\n    repeatV: 1,\n    fitScaleX: 1,\n    fitScaleY: 1,\n  };\n  if (!Number.isFinite(mediaAspect) || mediaAspect <= 0) return crop;\n\n  if (objectFit === \"cover\") {\n    if (planeAspect > mediaAspect) {\n      crop.repeatV = mediaAspect / planeAspect;\n    } else {\n      crop.repeatU = planeAspect / mediaAspect;\n    }\n  } else if (objectFit === \"contain\") {\n    if (planeAspect > mediaAspect) {\n      crop.fitScaleX = mediaAspect / planeAspect;\n    } else {\n      crop.fitScaleY = planeAspect / mediaAspect;\n    }\n  }\n\n  return crop;\n}\n\nexport function applyUvCrop(\n  uvAttribute: BufferAttribute | InterleavedBufferAttribute,\n  segments: number,\n  repeatU: number,\n  repeatV: number,\n) {\n  const offsetU = (1 - repeatU) / 2;\n  const offsetV = (1 - repeatV) / 2;\n\n  for (let iy = 0; iy <= segments; iy++) {\n    for (let ix = 0; ix <= segments; ix++) {\n      const index = iy * (segments + 1) + ix;\n      const u = ix / segments;\n      const v = 1 - iy / segments;\n      uvAttribute.setXY(index, u * repeatU + offsetU, v * repeatV + offsetV);\n    }\n  }\n\n  uvAttribute.needsUpdate = true;\n}\n\nexport type Pointer = {\n  uv: Vector2;\n  texUv: Vector2;\n  repeat: Vector2;\n  fit: Vector2;\n  hover: number;\n};\n\nexport type PointerTarget = Pointer | RefObject<Pointer>;\n\nexport function getPointerTarget(target: PointerTarget): Pointer {\n  if (\"current\" in target) {\n    return target.current;\n  }\n  return target;\n}\n\ntype UsePointerUvOptions = {\n  enabled: boolean;\n  getRect?: (el: HTMLElement) => DOMRect;\n};\n\nexport function usePointerUv(\n  el: RefObject<HTMLElement | null>,\n  { enabled, getRect }: UsePointerUvOptions,\n): RefObject<Pointer> {\n  const pointerRef = useRef<Pointer>({\n    uv: new Vector2(0.5, 0.5),\n    texUv: new Vector2(0.5, 0.5),\n    repeat: new Vector2(1, 1),\n    fit: new Vector2(1, 1),\n    hover: 0,\n  });\n\n  useEffect(() => {\n    if (!enabled) return;\n    const target = el.current;\n    if (!target) return;\n    const currentPointer = pointerRef.current;\n\n    const onMove = (event: PointerEvent) => {\n      const rect = getRect ? getRect(target) : target.getBoundingClientRect();\n      const width = rect.width || 1;\n      const height = rect.height || 1;\n      const x = (event.clientX - rect.left) / width;\n      const y = 1 - (event.clientY - rect.top) / height;\n      currentPointer.uv.set(x, y);\n      currentPointer.texUv.set(\n        x * currentPointer.fit.x + (1 - currentPointer.fit.x) / 2,\n        y * currentPointer.fit.y + (1 - currentPointer.fit.y) / 2,\n      );\n    };\n\n    const onEnter = () => {\n      currentPointer.hover = 1;\n    };\n    const onLeave = () => {\n      currentPointer.hover = 0;\n    };\n\n    target.addEventListener(\"pointermove\", onMove);\n    target.addEventListener(\"pointerenter\", onEnter);\n    target.addEventListener(\"pointerleave\", onLeave);\n\n    if (typeof target.matches === \"function\" && target.matches(\":hover\")) {\n      currentPointer.hover = 1;\n    }\n\n    return () => {\n      target.removeEventListener(\"pointermove\", onMove);\n      target.removeEventListener(\"pointerenter\", onEnter);\n      target.removeEventListener(\"pointerleave\", onLeave);\n    };\n  }, [enabled, el, getRect]);\n\n  return pointerRef;\n}\n\nfunction WebglTeleport() {\n  const items = new Map<string, ReactNode>();\n  const listeners = new Set<() => void>();\n  let snapshot: [string, ReactNode][] = [];\n\n  const emit = () => {\n    snapshot = Array.from(items.entries());\n    for (const listener of listeners) {\n      listener();\n    }\n  };\n\n  const subscribe = (listener: () => void) => {\n    listeners.add(listener);\n    return () => {\n      listeners.delete(listener);\n    };\n  };\n  const getSnapshot = () => snapshot;\n\n  function useItems() {\n    return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);\n  }\n\n  return {\n    In({ children }: { children: ReactNode }) {\n      const id = useId();\n\n      useIsoLayoutEffect(() => {\n        items.set(id, children);\n        emit();\n        return () => {\n          items.delete(id);\n          emit();\n        };\n      }, [id, children]);\n      return null;\n    },\n    useItems,\n    Out() {\n      const list = useItems();\n      return (\n        <>\n          {list.map(([id, node]) => (\n            <Suspense key={id} fallback={null}>\n              {node}\n            </Suspense>\n          ))}\n        </>\n      );\n    },\n  };\n}\n\nexport const webglTeleport = WebglTeleport();\nexport const effectTeleport = WebglTeleport();\n\nexport function WebglPortal() {\n  return <webglTeleport.Out />;\n}\n\nconst WebglContext = React.createContext<boolean>(false);\n\ntype WebglProviderProps = Omit<CanvasProps, \"children\" | \"eventSource\"> & {\n  children: ReactNode;\n  className?: string;\n  contained?: boolean;\n};\n\ntype WebglReadyOptions = {\n  scene?: Scene;\n  camera?: Camera;\n  enabled?: boolean;\n  onReady?: () => void;\n};\n\nexport function useWebglReady({\n  scene,\n  camera,\n  enabled = true,\n  onReady,\n}: WebglReadyOptions = {}) {\n  const [ready, setReady] = useState(false);\n  const gl = useThree((state) => state.gl);\n  const defaultScene = useThree((state) => state.scene);\n  const defaultCamera = useThree((state) => state.camera);\n  const onReadyRef = useRef(onReady);\n\n  useEffect(() => {\n    onReadyRef.current = onReady;\n  }, [onReady]);\n\n  const targetScene = scene ?? defaultScene;\n  const targetCamera = camera ?? defaultCamera;\n\n  useEffect(() => {\n    if (!enabled) return;\n    let active = true;\n\n    gl.compileAsync(targetScene, targetCamera).then(() => {\n      if (!active) return;\n      requestAnimationFrame(() => {\n        if (!active) return;\n        setReady(true);\n        onReadyRef.current?.();\n      });\n    });\n\n    return () => {\n      active = false;\n    };\n  }, [gl, targetScene, targetCamera, enabled]);\n\n  return ready;\n}\n\ntype CanvasStore = ReturnType<typeof useStore>;\nconst canvasStores = new Set<CanvasStore>();\nlet clockStart: number | null = null;\n\nfunction tick(data: FrameData) {\n  if (clockStart === null) clockStart = data.timestamp;\n\n  const elapsed = (data.timestamp - clockStart) / 1000;\n\n  let runGlobalEffects = true;\n  for (const store of canvasStores) {\n    const state = store.getState();\n    if (state.internal.active) {\n      advance(elapsed, runGlobalEffects, state);\n      runGlobalEffects = false;\n    }\n  }\n}\n\nfunction MotionFrameloop() {\n  const store = useStore();\n\n  useEffect(() => {\n    canvasStores.add(store);\n    if (canvasStores.size === 1) frame.postRender(tick, true);\n    return () => {\n      canvasStores.delete(store);\n      if (canvasStores.size === 0) cancelFrame(tick);\n    };\n  }, [store]);\n\n  return null;\n}\n\nfunction RootRender() {\n  const gl = useThree((state) => state.gl);\n  const scene = useThree((state) => state.scene);\n  const camera = useThree((state) => state.camera);\n\n  useFrame(() => gl.render(scene, camera), 1);\n\n  return null;\n}\n\nfunction Effects() {\n  const effects = effectTeleport.useItems();\n  const gl = useThree((state) => state.gl);\n  const mounted = effects.length > 0;\n\n  useEffect(() => {\n    if (!mounted) return;\n    return () => {\n      gl.autoClear = true;\n    };\n  }, [mounted, gl]);\n\n  if (!mounted) return <RootRender />;\n\n  return (\n    <EffectComposer key={effects.length} multisampling={0}>\n      <effectTeleport.Out />\n    </EffectComposer>\n  );\n}\n\nexport function WebglProvider({\n  children,\n  className,\n  style,\n  contained = false,\n  ...canvasProps\n}: WebglProviderProps) {\n  const [eventSource, setEventSource] = useState<ComponentRef<\"div\"> | null>(\n    null,\n  );\n\n  return (\n    <WebglContext.Provider value={true}>\n      <div\n        ref={setEventSource}\n        data-atelier-webgl=\"\"\n        className={className}\n        style={contained ? { position: \"relative\" } : { display: \"contents\" }}\n      >\n        <Canvas\n          eventPrefix=\"client\"\n          dpr={[1, 1.8]}\n          {...canvasProps}\n          frameloop=\"never\"\n          eventSource={eventSource ?? undefined}\n          style={{\n            position: contained ? \"absolute\" : \"fixed\",\n            inset: 0,\n            pointerEvents: \"none\",\n            ...style,\n          }}\n        >\n          <MotionFrameloop />\n          <WebglPortal />\n          <Effects />\n        </Canvas>\n\n        {children}\n      </div>\n    </WebglContext.Provider>\n  );\n}\n\ntype WebglImageProps = {\n  src: string;\n  alt: string;\n  material?: (map: Texture, pointer: PointerTarget) => React.ReactNode;\n  webglEnabled?: boolean;\n  segments?: number;\n  zIndex?: number;\n  autoReflow?: boolean;\n} & Omit<React.ComponentPropsWithoutRef<\"img\">, \"children\" | \"src\" | \"alt\">;\n\ntype ImagePlaneProps = {\n  el: RefObject<HTMLImageElement | null>;\n  src: string;\n  segments: number;\n  material?: (map: Texture, pointer: PointerTarget) => React.ReactNode;\n  pointer: PointerTarget;\n  zIndex: number;\n  autoReflow: boolean;\n};\n\nfunction ImagePlane({\n  el,\n  src,\n  segments,\n  material,\n  pointer,\n  zIndex,\n  autoReflow,\n}: ImagePlaneProps) {\n  const mesh = useRef<Mesh>(null);\n  const texture = useTexture(src);\n  const fitScale = useRef({ x: 1, y: 1 });\n  const size = useThree((state) => state.size);\n  const viewport = useThree((state) => state.viewport);\n  const gl = useThree((state) => state.gl);\n\n  useLayoutEffect(() => {\n    const target = el.current;\n    if (!target) return;\n\n    const measure = () => {\n      const m = mesh.current;\n      if (!m) return;\n      const rect = target.getBoundingClientRect();\n      if (!rect || rect.height <= 0) return;\n\n      const image = texture.image as HTMLImageElement;\n      if (!image || !image.height) return;\n\n      const crop = computeObjectFit(\n        rect.width / rect.height,\n        image.width / image.height,\n        getComputedStyle(target).objectFit,\n      );\n\n      fitScale.current.x = crop.fitScaleX;\n      fitScale.current.y = crop.fitScaleY;\n      const p = getPointerTarget(pointer);\n      p.repeat.set(crop.repeatU, crop.repeatV);\n      p.fit.set(\n        crop.repeatU / crop.fitScaleX,\n        crop.repeatV / crop.fitScaleY,\n      );\n\n      applyUvCrop(\n        m.geometry.attributes.uv,\n        segments,\n        crop.repeatU,\n        crop.repeatV,\n      );\n    };\n\n    measure();\n\n    const ro = new ResizeObserver(measure);\n    ro.observe(target);\n    ro.observe(document.body);\n    return () => ro.disconnect();\n  }, [el, texture, segments, pointer]);\n\n  useFrame(() => {\n    const m = mesh.current;\n    if (!m || !el.current || size.height <= 0) return;\n    const pxToWorld = viewport.height / size.height;\n    const fit = fitScale.current;\n\n    const canvasRect =\n      gl.domElement && typeof gl.domElement.getBoundingClientRect === \"function\"\n        ? gl.domElement.getBoundingClientRect()\n        : { left: 0, top: 0 };\n\n    if (autoReflow && el.current) {\n      const rect = el.current.getBoundingClientRect();\n      const relLeft = rect.left - canvasRect.left;\n      const relTop = rect.top - canvasRect.top;\n\n      m.position.x = (relLeft + rect.width / 2 - size.width / 2) * pxToWorld;\n      m.position.y = -(relTop + rect.height / 2 - size.height / 2) * pxToWorld;\n      m.scale.x = rect.width * pxToWorld * fit.x;\n      m.scale.y = rect.height * pxToWorld * fit.y;\n    }\n  });\n\n  return (\n    <mesh ref={mesh} renderOrder={zIndex}>\n      <planeGeometry args={[1, 1, segments, segments]} />\n      {material ? (\n        material(texture, pointer)\n      ) : (\n        <meshBasicMaterial map={texture} transparent />\n      )}\n    </mesh>\n  );\n}\n\nexport function WebglImage({\n  src,\n  alt,\n  className,\n  style,\n  material,\n  webglEnabled = true,\n  segments = 1,\n  zIndex = 0,\n  autoReflow = true,\n  ...rest\n}: WebglImageProps) {\n  const el = useRef<ComponentRef<\"img\">>(null);\n  const pointer = usePointerUv(el, { enabled: webglEnabled });\n\n  return (\n    <>\n      <img\n        ref={el}\n        src={src}\n        alt={alt}\n        className={className}\n        style={webglEnabled ? { ...style, opacity: 0 } : style}\n        {...rest}\n      />\n\n      {webglEnabled && (\n        <webglTeleport.In>\n          <ImagePlane\n            el={el}\n            src={src}\n            segments={segments}\n            material={material}\n            pointer={pointer}\n            zIndex={zIndex}\n            autoReflow={autoReflow}\n          />\n        </webglTeleport.In>\n      )}\n    </>\n  );\n}\n\ntype WebglVideoProps = {\n  src: string;\n  material?: (map: Texture, pointer: PointerTarget) => React.ReactNode;\n  webglEnabled?: boolean;\n  segments?: number;\n  zIndex?: number;\n  autoReflow?: boolean;\n} & Omit<React.ComponentPropsWithoutRef<\"video\">, \"children\" | \"src\">;\n\ntype VideoPlaneProps = {\n  el: RefObject<HTMLVideoElement | null>;\n  segments: number;\n  material?: (map: Texture, pointer: PointerTarget) => React.ReactNode;\n  pointer: PointerTarget;\n  zIndex: number;\n  autoReflow: boolean;\n};\n\nfunction VideoPlane({\n  el,\n  segments,\n  material,\n  pointer,\n  zIndex,\n  autoReflow,\n}: VideoPlaneProps) {\n  const mesh = useRef<Mesh>(null);\n  const [texture, setTexture] = useState<VideoTexture | null>(null);\n  const fitScale = useRef({ x: 1, y: 1 });\n  const size = useThree((state) => state.size);\n  const viewport = useThree((state) => state.viewport);\n  const gl = useThree((state) => state.gl);\n\n  useEffect(() => {\n    const video = el.current;\n    if (!video) return;\n\n    const videoTexture = new VideoTexture(video);\n    videoTexture.colorSpace = SRGBColorSpace;\n    queueMicrotask(() => {\n      setTexture(videoTexture);\n    });\n    return () => videoTexture.dispose();\n  }, [el]);\n\n  useLayoutEffect(() => {\n    const target = el.current;\n    if (!target || !texture) return;\n\n    const measure = () => {\n      const m = mesh.current;\n      if (!m) return;\n      const rect = target.getBoundingClientRect();\n      if (!rect || rect.height <= 0) return;\n\n      const video = texture.image as HTMLVideoElement;\n      if (!video || !video.videoHeight) return;\n\n      const crop = computeObjectFit(\n        rect.width / rect.height,\n        video.videoWidth / video.videoHeight,\n        getComputedStyle(target).objectFit,\n      );\n\n      fitScale.current.x = crop.fitScaleX;\n      fitScale.current.y = crop.fitScaleY;\n      const p = getPointerTarget(pointer);\n      p.repeat.set(crop.repeatU, crop.repeatV);\n      p.fit.set(\n        crop.repeatU / crop.fitScaleX,\n        crop.repeatV / crop.fitScaleY,\n      );\n\n      applyUvCrop(\n        m.geometry.attributes.uv,\n        segments,\n        crop.repeatU,\n        crop.repeatV,\n      );\n    };\n\n    measure();\n\n    target.addEventListener(\"loadedmetadata\", measure);\n    target.addEventListener(\"resize\", measure);\n\n    const ro = new ResizeObserver(measure);\n    ro.observe(target);\n    ro.observe(document.body);\n    return () => {\n      ro.disconnect();\n      target.removeEventListener(\"loadedmetadata\", measure);\n      target.removeEventListener(\"resize\", measure);\n    };\n  }, [el, texture, segments, pointer]);\n\n  useFrame(() => {\n    texture?.update();\n    const m = mesh.current;\n    if (!m || !el.current || size.height <= 0) return;\n    const pxToWorld = viewport.height / size.height;\n    const fit = fitScale.current;\n\n    const canvasRect =\n      gl.domElement && typeof gl.domElement.getBoundingClientRect === \"function\"\n        ? gl.domElement.getBoundingClientRect()\n        : { left: 0, top: 0 };\n\n    if (autoReflow && el.current) {\n      const rect = el.current.getBoundingClientRect();\n      const relLeft = rect.left - canvasRect.left;\n      const relTop = rect.top - canvasRect.top;\n\n      m.position.x = (relLeft + rect.width / 2 - size.width / 2) * pxToWorld;\n      m.position.y = -(relTop + rect.height / 2 - size.height / 2) * pxToWorld;\n      m.scale.x = rect.width * pxToWorld * fit.x;\n      m.scale.y = rect.height * pxToWorld * fit.y;\n    }\n  });\n\n  if (!texture) return null;\n\n  return (\n    <mesh ref={mesh} renderOrder={zIndex}>\n      <planeGeometry args={[1, 1, segments, segments]} />\n      {material ? (\n        material(texture, pointer)\n      ) : (\n        <meshBasicMaterial map={texture} transparent />\n      )}\n    </mesh>\n  );\n}\n\nexport function WebglVideo({\n  src,\n  className,\n  style,\n  material,\n  webglEnabled = true,\n  segments = 1,\n  zIndex = 0,\n  autoReflow = true,\n  autoPlay = true,\n  muted = true,\n  loop = true,\n  playsInline = true,\n  ...rest\n}: WebglVideoProps) {\n  const el = useRef<ComponentRef<\"video\">>(null);\n  const pointer = usePointerUv(el, { enabled: webglEnabled });\n\n  return (\n    <>\n      <video\n        ref={el}\n        src={src}\n        className={className}\n        style={webglEnabled ? { ...style, opacity: 0 } : style}\n        autoPlay={autoPlay}\n        muted={muted}\n        loop={loop}\n        playsInline={playsInline}\n        {...rest}\n      />\n\n      {webglEnabled && (\n        <webglTeleport.In>\n          <VideoPlane\n            el={el}\n            segments={segments}\n            material={material}\n            pointer={pointer}\n            zIndex={zIndex}\n            autoReflow={autoReflow}\n          />\n        </webglTeleport.In>\n      )}\n    </>\n  );\n}\n\nconst ROTATION_SPEED = 0.1;\nconst INITIAL_OPACITY = 0.22;\nconst DISPLACEMENT_DAMPING = 6.3;\nconst VELOCITY_DAMPING = 6.3;\nconst IDLE_VELOCITY_DAMPING = 0.3;\nconst SPAWN_SPACING = 0.2;\nconst MIN_SPAWN_INTERVAL = 1 / 60;\nconst MIN_SPAWN_DISTANCE = 0.005;\nconst MIN_VISIBLE_OPACITY = 0.002;\n\ntype Splat = Mesh<PlaneGeometry, MeshBasicMaterial>;\n\nconst RIPPLE_BRUSH =\n  \"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAIAAAACACAMAAAD04JH5AAAAnFBMVEUAAAD////////////////////////////////////////////////////+/v7////9/f3////9/f39/f3////////////9/f3+/v79/f3////////+/v79/f3+/v7////////+/v7////+/v7////+/v7+/v7////////////+/v7////+/v7+/v7+/v7+/v7////////////////Clg1EAAAANHRSTlMACAwRBBUcICQZKDM4LEAwSjxGTml2XEOCUmRhh1eLcXpUbpl+Wo+1kqumsJafnKK+ucPKHu8hDAAADa9JREFUeNrslcl6olAUhJOWCCKDgDJeJkEEFBzy/u/Wda7na9PboK6sRRbJon7qVN18vPXWW2/9UspNH68Wm3/e9XoIcv9zFzG8EkJRyH42m31BM4gYXgjB9l+LxVxqISkY4gUM5I9vn6uqpum6rmmqCghmeAGC/HzYa7ppL0m2aeqaykGA4ZkI/PmzxVzT7aVhWUEQWJZjAAIMKjM8sZMc/0LVzaUTuJ4XRZHnrdzAMgwwAAIMHANTPMOfPt+wXC/1kySOE39XRh4zSAQeBpfysfbsv3Rcr/TjbU7arteJn0bIgW7BdfgxT+Wx54e/Cf9oF283RR1CdbEBRLJLcQvrXx1AwY14FMInn1/6l/46L0LRNG3bNFkYgmEdyxhwCbkLnucNQXnQ+rh+FvlvatFU4zgM41gdGxHWhJDsZBscAxTLWxZAeAiBwuvXTcNalcl6E2bH8dR3UH8axqrNgLBFCrvSW61cl/dpm5q6YIKp/jOuP/x96T/03WEPHQ5gGCmFAghIYZemZVqWkeyErTPBg+bH/jX5H/aXy/l8uYCh6zkE2sQ6juWPxC+9wFkSwWSA//3zWrRj35E/BAQQUAYZLeKmoqBtxH7kWiD4mkiAAPD8qtI/8mP0vzkOHQI4XyEQUAQDIhAYZU3bFBkk0IokXTm2PieCCQB8AOq/R/sPRVv1EuB88+cbNEQghGjaIwYyHltR53HpGrY27QgKv/94/j0MIC9oAQTwswOD9CdlbTXQPuh3WZ0nUWDQEfAeTQ3AsPgBDMlDEtxXQAcQMvxG9nNPXH2VFVt/5XAEEwOwjQANlAAwGQf6SJh3PduHNZpX42/Vqduf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\";\n\nconst vertexShader = `\nvarying vec2 vUv;\nvarying vec2 vScreenUv;\n\nvoid main() {\n    vUv = uv;\n    vec4 pos = projectionMatrix * modelViewMatrix * vec4(position, 1.0);\n    vScreenUv = pos.xy / pos.w * 0.5 + 0.5;\n    gl_Position = pos;\n}`;\n\nconst fragmentShader = `\nprecision highp float;\nuniform sampler2D uTexture;\nuniform sampler2D uDisplacement;\nuniform float uDisplacementIntensity;\nvarying vec2 vUv;\nvarying vec2 vScreenUv;\n\n#define PI 3.14159265\n\nvoid main() {\n    vec4 displacement = texture2D(uDisplacement, vScreenUv);\n    float theta = displacement.r * 2.0 * PI;\n    vec2 direction = vec2(sin(theta), cos(theta));\n    vec2 displacedUv = vUv + direction * displacement.r * uDisplacementIntensity;\n    vec4 color = texture2D(uTexture, displacedUv);\n    gl_FragColor = color;\n}`;\n\ntype LiquidMediaMaterialProps = {\n  map: Texture;\n  pointer: PointerTarget;\n} & Pick<\n  LiquidEffectProps,\n  \"rippleMap\" | \"intensity\" | \"radius\" | \"expandRate\" | \"decayRate\"\n>;\n\ntype Uniforms = {\n  uTexture: { value: Texture | null };\n  uDisplacement: { value: Texture | null };\n  uDisplacementIntensity: { value: number };\n};\n\nexport type LiquidEffectProps = {\n  rippleMap?: Texture;\n  intensity?: number;\n  radius?: number;\n  expandRate?: number;\n  decayRate?: number;\n  segments?: number;\n  webglEnabled?: boolean;\n};\n\ntype LiquidMediaImageProps = LiquidEffectProps & {\n  type?: \"image\";\n  src: string;\n  alt: string;\n} & Omit<React.ComponentPropsWithoutRef<\"img\">, \"src\" | \"alt\">;\n\ntype LiquidMediaVideoProps = LiquidEffectProps & {\n  type: \"video\";\n  src: string;\n} & Omit<React.ComponentPropsWithoutRef<\"video\">, \"src\">;\n\nexport type LiquidMediaProps = LiquidMediaImageProps | LiquidMediaVideoProps;\n\nfunction LiquidMediaMaterial({\n  map,\n  pointer,\n  rippleMap,\n  intensity = 0.2,\n  radius = 12,\n  expandRate = 11,\n  decayRate = 3,\n}: LiquidMediaMaterialProps) {\n  const { viewport, size, gl } = useThree();\n\n  const defaultBrush = useTexture(RIPPLE_BRUSH);\n  const brush = rippleMap ?? defaultBrush;\n  const anchorRef = useRef<Group>(null);\n  const splatIndex = useRef(0);\n  const spriteRefs = useRef<Splat[]>([]);\n  const spriteScene = useMemo(() => new Scene(), []);\n  const spriteCamera = useMemo(() => {\n    const cam = new OrthographicCamera(\n      -viewport.width / 2,\n      viewport.width / 2,\n      viewport.height / 2,\n      -viewport.height / 2,\n      0,\n      1,\n    );\n    cam.updateProjectionMatrix();\n    return cam;\n  }, [viewport.width, viewport.height]);\n\n  const materialRef = useRef<ShaderMaterial>(null);\n  const mouse = useRef({\n    x: 0,\n    y: 0,\n    velocity: 0,\n    spawnX: Infinity,\n    spawnY: Infinity,\n    spawnElapsed: Infinity,\n  });\n\n  const uniforms = useMemo<Uniforms>(\n    () => ({\n      uTexture: { value: map },\n      uDisplacement: { value: null },\n      uDisplacementIntensity: { value: 0 },\n    }),\n    [map],\n  );\n\n  const safeWidth = Math.max(1, size.width);\n  const safeHeight = Math.max(1, size.height);\n\n  const FBO = useFBO(safeWidth, safeHeight, {\n    minFilter: LinearFilter,\n    magFilter: LinearFilter,\n  });\n\n  useFrame((_, delta) => {\n    const parent = anchorRef.current?.parent as Mesh | null;\n    const mat = materialRef.current;\n    if (!parent || !mat || delta <= 0) return;\n\n    const sprites = spriteRefs.current;\n    const _mouse = mouse.current;\n    const p = getPointerTarget(pointer);\n    const hovering = p.hover > 0.5;\n\n    const pointerX = parent.position.x + (p.uv.x - 0.5) * parent.scale.x;\n    const pointerY = parent.position.y + (p.uv.y - 0.5) * parent.scale.y;\n\n    if (_mouse.spawnX === Infinity && hovering) {\n      _mouse.x = pointerX;\n      _mouse.y = pointerY;\n      _mouse.spawnX = pointerX;\n      _mouse.spawnY = pointerY;\n    }\n\n    const dx = pointerX - _mouse.x;\n    const dy = pointerY - _mouse.y;\n\n    const speed = Math.sqrt(dx * dx + dy * dy) / (delta * 60);\n\n    _mouse.x = pointerX;\n    _mouse.y = pointerY;\n\n    if (hovering) {\n      _mouse.velocity = MathUtils.damp(\n        _mouse.velocity,\n        speed,\n        VELOCITY_DAMPING,\n        delta,\n      );\n    } else {\n      _mouse.velocity = MathUtils.damp(\n        _mouse.velocity,\n        0,\n        IDLE_VELOCITY_DAMPING,\n        delta,\n      );\n    }\n\n    const scale = (radius * Math.min(viewport.width, viewport.height)) / 100;\n    const spacing = Math.max(scale * SPAWN_SPACING, MIN_SPAWN_DISTANCE);\n    const spawnDx = pointerX - _mouse.spawnX;\n    const spawnDy = pointerY - _mouse.spawnY;\n    const spawnDist = Math.sqrt(spawnDx * spawnDx + spawnDy * spawnDy);\n\n    _mouse.spawnElapsed += delta;\n\n    if (\n      hovering &&\n      spawnDist > spacing &&\n      _mouse.spawnElapsed > MIN_SPAWN_INTERVAL\n    ) {\n      const sprite = sprites[splatIndex.current];\n\n      if (sprite) {\n        sprite.visible = true;\n        sprite.position.set(pointerX, pointerY, 0);\n        sprite.scale.set(scale, scale, 1);\n        sprite.material.opacity = INITIAL_OPACITY;\n      }\n\n      splatIndex.current = (splatIndex.current + 1) % 100;\n      _mouse.spawnX = pointerX;\n      _mouse.spawnY = pointerY;\n      _mouse.spawnElapsed = 0;\n    }\n\n    for (const sprite of sprites) {\n      if (sprite.visible) {\n        sprite.rotation.z += 2 * delta * ROTATION_SPEED;\n        sprite.material.opacity = MathUtils.damp(\n          sprite.material.opacity,\n          0,\n          MathUtils.clamp(decayRate, 3, 10),\n          delta,\n        );\n        sprite.scale.x += delta * expandRate * scale;\n        sprite.scale.y = sprite.scale.x;\n        if (sprite.material.opacity < MIN_VISIBLE_OPACITY)\n          sprite.visible = false;\n      }\n    }\n\n    gl.setRenderTarget(FBO);\n    gl.render(spriteScene, spriteCamera);\n    gl.setRenderTarget(null);\n\n    mat.uniforms.uDisplacement.value = FBO.texture;\n    mat.uniforms.uDisplacementIntensity.value = MathUtils.damp(\n      mat.uniforms.uDisplacementIntensity.value,\n      intensity * _mouse.velocity * 5,\n      DISPLACEMENT_DAMPING,\n      delta,\n    );\n  });\n\n  const portal = useMemo(\n    () =>\n      createPortal(\n        <group>\n          {Array.from({ length: 100 }, (_, i) => (\n            <mesh\n              key={i}\n              ref={(mesh) => {\n                if (mesh) spriteRefs.current[i] = mesh as Splat;\n              }}\n              visible={false}\n              rotation-z={(i * 0.628318) % (Math.PI * 2)}\n            >\n              <planeGeometry args={[1, 1]} />\n              <meshBasicMaterial\n                map={brush}\n                transparent\n                blending={AdditiveBlending}\n                depthTest={false}\n                depthWrite={false}\n              />\n            </mesh>\n          ))}\n        </group>,\n        spriteScene,\n      ),\n    [brush, spriteScene],\n  );\n\n  return (\n    <>\n      <group ref={anchorRef} />\n\n      <shaderMaterial\n        ref={materialRef}\n        attach=\"material\"\n        vertexShader={vertexShader}\n        fragmentShader={fragmentShader}\n        uniforms={uniforms}\n      />\n\n      {portal}\n    </>\n  );\n}\n\nconst REDUCED_MOTION_QUERY = \"(prefers-reduced-motion: reduce)\";\n\nfunction subscribeReducedMotion(onChange: () => void) {\n  const media = window.matchMedia(REDUCED_MOTION_QUERY);\n  media.addEventListener(\"change\", onChange);\n  return () => media.removeEventListener(\"change\", onChange);\n}\n\nfunction getReducedMotion() {\n  return window.matchMedia(REDUCED_MOTION_QUERY).matches;\n}\n\nfunction LiquidMediaInner(props: LiquidMediaProps) {\n  const {\n    rippleMap,\n    intensity,\n    radius,\n    expandRate,\n    decayRate,\n    segments,\n    webglEnabled,\n    ...rest\n  } = props;\n\n  const material = (map: Texture, pointer: PointerTarget) => (\n    <LiquidMediaMaterial\n      map={map}\n      pointer={pointer}\n      rippleMap={rippleMap}\n      intensity={intensity}\n      radius={radius}\n      expandRate={expandRate}\n      decayRate={decayRate}\n    />\n  );\n\n  if (rest.type === \"video\") {\n    const { type: _type, ...videoProps } = rest;\n    return (\n      <WebglVideo\n        segments={segments}\n        webglEnabled={webglEnabled}\n        material={material}\n        {...videoProps}\n      />\n    );\n  }\n\n  const { type: _type, ...imageProps } = rest;\n  return (\n    <WebglImage\n      segments={segments}\n      webglEnabled={webglEnabled}\n      material={material}\n      {...imageProps}\n    />\n  );\n}\n\nexport function LiquidMedia(\n  props: LiquidMediaProps & { containerClassName?: string },\n) {\n  const hasProvider = React.useContext(WebglContext);\n  const reduceMotion = useSyncExternalStore(\n    subscribeReducedMotion,\n    getReducedMotion,\n    () => false,\n  );\n  const { containerClassName, ...mediaProps } = props;\n\n  const media = reduceMotion ? (\n    <LiquidMediaInner {...mediaProps} webglEnabled={false} />\n  ) : (\n    <LiquidMediaInner {...mediaProps} />\n  );\n\n  if (hasProvider) return media;\n\n  if (reduceMotion) {\n    return (\n      <div className={cn(\"relative w-full h-full\", containerClassName)}>\n        {media}\n      </div>\n    );\n  }\n\n  return (\n    <WebglProvider\n      contained\n      className={cn(\"relative w-full h-full\", containerClassName)}\n    >\n      {media}\n    </WebglProvider>\n  );\n}\n\nexport type LiquidMediaShowcaseProps = {\n  src?: string;\n  alt?: string;\n  caption?: string;\n  className?: string;\n  intensity?: number;\n  radius?: number;\n  expandRate?: number;\n  decayRate?: number;\n  segments?: number;\n  webglEnabled?: boolean;\n};\n\nexport function LiquidMediaShowcase({\n  src = \"/liquid-media-demo.jpg\",\n  alt = \"Editorial portrait\",\n  caption = \"Editorial Portrait\",\n  className,\n  intensity,\n  radius,\n  expandRate,\n  decayRate,\n  segments,\n  webglEnabled = true,\n}: LiquidMediaShowcaseProps) {\n  return (\n    <div\n      className={cn(\n        \"group relative w-full max-w-4xl overflow-hidden rounded-2xl border border-zinc-200/80 bg-zinc-950 shadow-2xl dark:border-white/10 select-none\",\n        className,\n      )}\n    >\n      <LiquidMedia\n        src={src}\n        alt={alt}\n        intensity={intensity}\n        radius={radius}\n        expandRate={expandRate}\n        decayRate={decayRate}\n        segments={segments}\n        webglEnabled={webglEnabled}\n        className=\"w-full h-auto aspect-16/10 object-cover\"\n      />\n      {caption ? (\n        <div className=\"pointer-events-none absolute inset-0 flex items-center justify-center p-4\">\n          <span className=\"font-serif text-lg sm:text-2xl md:text-3xl font-normal tracking-wide text-white/95 drop-shadow-[0_2px_12px_rgba(0,0,0,0.6)]\">\n            {caption}\n          </span>\n        </div>\n      ) : null}\n    </div>\n  );\n}\n\nexport type LiquidMediaPresetId =\n  | \"silk\"\n  | \"glass\"\n  | \"ripple\"\n  | \"tidal\"\n  | \"custom\";\n\nexport type LiquidMediaImageId = \"portrait\" | \"architecture\" | \"ocean\";\n\nexport interface LiquidMediaPreset {\n  id: LiquidMediaPresetId;\n  label: string;\n  intensity: number;\n  radius: number;\n  expandRate: number;\n  decayRate: number;\n}\n\nexport const LIQUID_MEDIA_PRESETS: readonly LiquidMediaPreset[] = [\n  {\n    id: \"silk\",\n    label: \"Silk Flow\",\n    intensity: 0.12,\n    radius: 9,\n    expandRate: 7,\n    decayRate: 2.2,\n  },\n  {\n    id: \"glass\",\n    label: \"Liquid Glass\",\n    intensity: 0.22,\n    radius: 12,\n    expandRate: 11,\n    decayRate: 3.0,\n  },\n  {\n    id: \"ripple\",\n    label: \"Deep Ripple\",\n    intensity: 0.42,\n    radius: 18,\n    expandRate: 16,\n    decayRate: 2.6,\n  },\n  {\n    id: \"tidal\",\n    label: \"Tidal Surge\",\n    intensity: 0.68,\n    radius: 24,\n    expandRate: 22,\n    decayRate: 3.4,\n  },\n] as const;\n\nexport const LIQUID_MEDIA_IMAGES: Record<\n  LiquidMediaImageId,\n  { label: string; src: string; caption: string }\n> = {\n  portrait: {\n    label: \"Portrait\",\n    src: \"/liquid-media-demo.jpg\",\n    caption: \"Editorial Portrait\",\n  },\n  architecture: {\n    label: \"Architecture\",\n    src: \"https://images.unsplash.com/photo-1600585154340-be6161a56a0c?w=1600&auto=format&fit=crop&q=80\",\n    caption: \"Modernist Villa\",\n  },\n  ocean: {\n    label: \"Ocean\",\n    src: \"https://images.unsplash.com/photo-1507525428034-b723cf961d3e?w=1600&auto=format&fit=crop&q=80\",\n    caption: \"Calm Horizon\",\n  },\n};\n\nexport default LiquidMedia;\n"
    }
  ]
}
