/* eslint react-hooks/exhaustive-deps: 1 */ import * as React from 'react' import * as THREE from 'three' import { forwardRef, useEffect, useImperativeHandle, useRef } from 'react' import { useThree } from '@react-three/fiber' import { suspend } from 'suspend-react' import { type default as Hls, Events } from 'hls.js' const IS_BROWSER = typeof window !== 'undefined' && typeof window.document?.createElement === 'function' && typeof window.navigator?.userAgent === 'string' let _HLSModule: typeof import('hls.js') | null = null async function getHls(...args: ConstructorParameters) { _HLSModule ??= await import('hls.js') // singleton const Ctor = _HLSModule.default if (Ctor.isSupported()) { return new Ctor(...args) } return null } export function useVideoTexture( srcOrSrcObject: HTMLVideoElement['src' | 'srcObject'], { unsuspend = 'loadedmetadata', start = true, hls: hlsConfig = {}, crossOrigin = 'anonymous', muted = true, loop = true, playsInline = true, onVideoFrame, ...videoProps }: { /** Event name that will unsuspend the video */ unsuspend?: keyof HTMLVideoElementEventMap /** Auto start the video once unsuspended */ start?: boolean /** HLS config */ hls?: Parameters[0] /** * request Video Frame Callback (rVFC) * * @see https://web.dev/requestvideoframecallback-rvfc/ * @see https://www.remotion.dev/docs/video-manipulation * */ onVideoFrame?: VideoFrameRequestCallback } & Partial> = {} ) { const gl = useThree((state) => state.gl) const hlsRef = useRef(null) const texture = suspend( () => new Promise(async (res) => { let src: HTMLVideoElement['src'] | undefined = undefined let srcObject: HTMLVideoElement['srcObject'] | undefined = undefined if (typeof srcOrSrcObject === 'string') { src = srcOrSrcObject } else { srcObject = srcOrSrcObject } const video = Object.assign(document.createElement('video'), { src, srcObject, crossOrigin, loop, muted, playsInline, ...videoProps, }) // hlsjs extension if (src && IS_BROWSER && src.endsWith('.m3u8')) { const hls = (hlsRef.current = await getHls(hlsConfig)) if (hls) { hls.on(Events.MEDIA_ATTACHED, () => void hls.loadSource(src)) hls.attachMedia(video) } } const texture = new THREE.VideoTexture(video) if ('colorSpace' in texture) (texture as any).colorSpace = (gl as any).outputColorSpace else texture.encoding = gl.outputEncoding video.addEventListener(unsuspend, () => res(texture)) }), [srcOrSrcObject] ) const video = texture.source.data as HTMLVideoElement useVideoFrame(video, onVideoFrame) useEffect(() => { start && texture.image.play() return () => { if (hlsRef.current) { hlsRef.current.destroy() hlsRef.current = null } } }, [texture, start]) return texture } // // VideoTexture // type UseVideoTextureParams = Parameters type VideoTexture = ReturnType export type VideoTextureProps = { children?: (texture: VideoTexture) => React.ReactNode src: UseVideoTextureParams[0] } & UseVideoTextureParams[1] export const VideoTexture = /* @__PURE__ */ forwardRef( ({ children, src, ...config }, fref) => { const texture = useVideoTexture(src, config) useEffect(() => { return () => void texture.dispose() }, [texture]) useImperativeHandle(fref, () => texture, [texture]) // expose texture through ref return <>{children?.(texture)} } ) // rVFC hook const useVideoFrame = (video: HTMLVideoElement, f?: VideoFrameRequestCallback) => { useEffect(() => { if (!f) return if (!video.requestVideoFrameCallback) return let handle: ReturnType<(typeof video)['requestVideoFrameCallback']> const callback: VideoFrameRequestCallback = (...args) => { f(...args) handle = video.requestVideoFrameCallback(callback) } video.requestVideoFrameCallback(callback) return () => video.cancelVideoFrameCallback(handle) }, [video, f]) }