{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"webgl-video","type":"registry:component","title":"Webgl Video","description":"A WebGL plane that mirrors a video.","meta":{"pro":false},"docs":"Usage: https://atelier-ui.com/r/webgl-video.md\nDocs: https://atelier-ui.com/docs/foundation/primitive/webgl-video","dependencies":["three","@types/three","@react-three/fiber"],"registryDependencies":["https://atelier-ui.com/r/webgl-portal.json","https://atelier-ui.com/r/agent-rules.json"],"files":[{"path":"registry/webgl-video/webgl-video.tsx","type":"registry:component","target":"components/webgl-video/webgl-video.tsx","content":"\"use client\"\n\nimport { useFrame } from \"@react-three/fiber\"\nimport { type ComponentRef, type RefObject, useLayoutEffect, useRef, useState } from \"react\"\nimport { type Mesh, SRGBColorSpace, type Texture, VideoTexture } from \"three\"\nimport { useDomPlane } from \"../../hooks/use-dom-plane\"\nimport { type Pointer, usePointerUv } from \"../../hooks/use-pointer-uv\"\nimport { applyUvCrop, computeObjectFit } from \"../../lib/object-fit\"\nimport { webglTeleport } from \"../webgl-portal/webgl-portal\"\n\nexport type { Pointer }\n\ntype WebglVideoProps = {\n    src: string\n    material?: (map: Texture, pointer: Pointer) => React.ReactNode\n    webglEnabled?: boolean\n    segments?: number\n    zIndex?: number\n    /**\n     * Re-measures the DOM rect every frame so the plane follows animated parents (motion, parallax).\n     * Costs one layout read per frame, so only enable it when needed.\n     */\n    autoReflow?: boolean\n} & Omit<React.ComponentPropsWithoutRef<\"video\">, \"children\" | \"src\">\n\ntype PlaneProps = {\n    el: RefObject<HTMLVideoElement | null>\n    segments: number\n    material?: (map: Texture, pointer: Pointer) => React.ReactNode\n    pointer: Pointer\n    uvFit: RefObject<{ x: number; y: number }>\n    zIndex: number\n    autoReflow: boolean\n}\n\nfunction Plane({ el, segments, material, pointer, uvFit, zIndex, autoReflow }: PlaneProps) {\n    const mesh = useRef<Mesh>(null)\n    const [texture, setTexture] = useState<VideoTexture | null>(null)\n    const fitScale = useRef({ x: 1, y: 1 })\n    const measureBounds = useDomPlane(el, mesh, { autoReflow, fitScale })\n\n    useLayoutEffect(() => {\n        const video = el.current\n        if (!video) return\n\n        /*\n         * Build the texture from the DOM <video> itself so a single element\n         * decodes once. VideoTexture pulls each new frame from it.\n         */\n        const videoTexture = new VideoTexture(video)\n        videoTexture.colorSpace = SRGBColorSpace\n        setTexture(videoTexture)\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 = measureBounds()\n            if (!rect) return\n\n            /*\n             * videoWidth/Height are 0 until metadata loads, which yields an\n             * invalid aspect, so computeObjectFit skips cropping until then.\n             */\n            const video = texture.image as HTMLVideoElement\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            pointer.repeat.set(crop.repeatU, crop.repeatV)\n            uvFit.current.x = crop.repeatU / crop.fitScaleX\n            uvFit.current.y = crop.repeatV / crop.fitScaleY\n\n            applyUvCrop(m.geometry.attributes.uv, segments, crop.repeatU, crop.repeatV)\n        }\n\n        measure()\n\n        /* Re-measure once the video reports its intrinsic size. */\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, uvFit, pointer, measureBounds])\n\n    useFrame(() => {\n        /* Browsers without requestVideoFrameCallback need an explicit pull. */\n        texture?.update()\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 = false,\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 uvFit = useRef({ x: 1, y: 1 })\n    const pointer = usePointerUv(el, { enabled: webglEnabled, uvFit })\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                    <Plane\n                        el={el}\n                        segments={segments}\n                        material={material}\n                        pointer={pointer}\n                        uvFit={uvFit}\n                        zIndex={zIndex}\n                        autoReflow={autoReflow}\n                    />\n                </webglTeleport.In>\n            )}\n        </>\n    )\n}\n"},{"path":"registry/hooks/use-pointer-uv.ts","type":"registry:hook","target":"hooks/use-pointer-uv.ts","content":"import { type RefObject, useEffect, useMemo } from \"react\"\nimport { Vector2 } from \"three\"\n\nexport type Pointer = {\n    uv: Vector2\n    texUv: Vector2\n    repeat: Vector2\n    hover: number\n}\n\ntype UsePointerUvOptions = {\n    enabled: boolean\n    /**\n     * Maps element UVs into cropped texture UVs when object-fit trims the\n     * media. Defaults to identity, so `texUv` mirrors `uv`.\n     */\n    uvFit?: RefObject<{ x: number; y: number }>\n    getRect?: (el: HTMLElement) => DOMRect\n}\n\n/**\n * Tracks the cursor over a DOM element as normalized UVs, mutated in place so\n * shader materials can read it every frame without re-rendering React.\n */\nexport function usePointerUv(\n    el: RefObject<HTMLElement | null>,\n    { enabled, uvFit, getRect }: UsePointerUvOptions,\n): Pointer {\n    const pointer = useMemo<Pointer>(() => {\n        return {\n            uv: new Vector2(0.5, 0.5),\n            texUv: new Vector2(0.5, 0.5),\n            repeat: new Vector2(1, 1),\n            hover: 0,\n        }\n    }, [])\n\n    useEffect(() => {\n        if (!enabled) return\n        const target = el.current\n        if (!target) return\n\n        /*\n         * Pointer events still fire on the DOM element through opacity:0,\n         * so the browser tells us when the cursor is over it.\n         */\n        const onMove = (event: PointerEvent) => {\n            const rect = getRect ? getRect(target) : target.getBoundingClientRect()\n            const x = (event.clientX - rect.left) / rect.width\n            const y = 1 - (event.clientY - rect.top) / rect.height\n            const fit = uvFit?.current ?? { x: 1, y: 1 }\n            pointer.uv.set(x, y)\n            pointer.texUv.set(x * fit.x + (1 - fit.x) / 2, y * fit.y + (1 - fit.y) / 2)\n        }\n\n        const onEnter = () => (pointer.hover = 1)\n        const onLeave = () => (pointer.hover = 0)\n\n        target.addEventListener(\"pointermove\", onMove)\n        target.addEventListener(\"pointerenter\", onEnter)\n        target.addEventListener(\"pointerleave\", onLeave)\n\n        /*\n         * Hover in too fast and pointerenter fires before these listeners\n         * attach, so seed hover from the live :hover state instead.\n         */\n        if (target.matches(\":hover\")) pointer.hover = 1\n\n        return () => {\n            target.removeEventListener(\"pointermove\", onMove)\n            target.removeEventListener(\"pointerenter\", onEnter)\n            target.removeEventListener(\"pointerleave\", onLeave)\n        }\n    }, [enabled, el, pointer, uvFit, getRect])\n\n    return pointer\n}\n"},{"path":"registry/hooks/use-dom-plane.ts","type":"registry:hook","target":"hooks/use-dom-plane.ts","content":"import { useFrame, useThree } from \"@react-three/fiber\"\nimport { type RefObject, useCallback, useRef } from \"react\"\nimport type { Mesh } from \"three\"\n\ntype UseDomPlaneOptions = {\n    /**\n     * Re-measures the DOM rect every frame so the plane follows animated\n     * parents (motion, parallax). Costs one layout read per frame.\n     */\n    autoReflow: boolean\n    fitScale?: RefObject<{ x: number; y: number }>\n    getRect?: (el: HTMLElement) => DOMRect\n}\n\n/**\n * Positions and scales a mesh to cover a DOM element on the shared canvas.\n * Scroll is applied every frame. Layout changes are not observed here: the\n * caller calls `measureBounds` when the element resizes or repaints.\n */\nexport function useDomPlane(\n    el: RefObject<HTMLElement | null>,\n    mesh: RefObject<Mesh | null>,\n    { autoReflow, fitScale, getRect }: UseDomPlaneOptions,\n) {\n    const size = useThree((state) => state.size)\n    const viewport = useThree((state) => state.viewport)\n    const bounds = useRef({ x: 0, y: 0, width: 0, height: 0 })\n\n    const measureBounds = useCallback(() => {\n        const target = el.current\n        if (!target) return null\n\n        /*\n         * Rect in document coords so viewport position later needs only\n         * window.scrollX/Y, instead of re-measuring bounds every render.\n         */\n        const rect = getRect ? getRect(target) : target.getBoundingClientRect()\n        bounds.current.x = rect.left + window.scrollX\n        bounds.current.y = rect.top + window.scrollY\n        bounds.current.width = rect.width\n        bounds.current.height = rect.height\n        return rect\n    }, [el, getRect])\n\n    useFrame(() => {\n        const m = mesh.current\n        if (!m) return\n        const pxToWorld = viewport.height / size.height\n        const fit = fitScale?.current ?? { x: 1, y: 1 }\n\n        const transitioning = document.documentElement.hasAttribute(\"data-atelier-transitioning\")\n\n        if ((autoReflow || transitioning) && el.current) {\n            const rect = getRect ? getRect(el.current) : el.current.getBoundingClientRect()\n            m.position.x = (rect.left + rect.width / 2 - size.width / 2) * pxToWorld\n            m.position.y = -(rect.top + 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            return\n        }\n\n        const { x, y, width, height } = bounds.current\n        m.position.x = (x + width / 2 - window.scrollX - size.width / 2) * pxToWorld\n        m.position.y = -(y + height / 2 - window.scrollY - size.height / 2) * pxToWorld\n        m.scale.x = width * pxToWorld * fit.x\n        m.scale.y = height * pxToWorld * fit.y\n    })\n\n    return measureBounds\n}\n"},{"path":"registry/lib/object-fit.ts","type":"registry:lib","target":"lib/object-fit.ts","content":"import type { BufferAttribute, InterleavedBufferAttribute } from \"three\"\n\nexport type ObjectFitCrop = {\n    repeatU: number\n    repeatV: number\n    fitScaleX: number\n    fitScaleY: number\n}\n\n/**\n * Replicates CSS object-fit on a WebGL plane: cover crops via UV repeat,\n * contain shrinks the mesh scale (UVs alone can't letterbox).\n *\n * Returns the neutral crop while the media aspect is unknown (e.g. a video\n * before its metadata loads reports 0x0).\n */\nexport function computeObjectFit(\n    planeAspect: number,\n    mediaAspect: number,\n    objectFit: string,\n): ObjectFitCrop {\n    const crop: ObjectFitCrop = { repeatU: 1, repeatV: 1, fitScaleX: 1, fitScaleY: 1 }\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\n/** Rewrites a plane's UV grid so the texture samples the cropped region. */\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"}]}