components/VideoCompositor.jsx

import React, { useCallback, useRef, useState } from "react";

import PropTypes from "prop-types";
import renderUnprocessedVideo from "../functions/renderUnprocessedVideo";
import useInterval from "../hooks/useInterval";
import useVideoStream from "../hooks/useVideoStream";

/**
 * Renders a video stream to a canvas and provides callback functions for background and foreground effects.
 *
 * @module VideoCompositor
 * @see module:useInterval
 * @see module:useVideoStream
 */
export default function VideoCompositor({
  fps = 30,
  onCaptureStream,
  onRenderBackground,
  onRenderForeground,
  style,
  videoTrackConstraints,
}) {
  const canvasElement = useRef({ current: {} });
  const videoElement = useRef({ current: {} });

  const [hasLoadedVideoData, setHasLoadedVideoData] = useState(false);
  const [{ width, height }, setVideoSettings] = useState({
    width: undefined,
    height: undefined,
  });

  /**
   * A callback function that is passed the newly-loaded MediaStream.
   *
   * @function
   * @param {MediaStream} stream The MediaStream that was loaded from the video device.
   */
  const handleOnLoadedVideoStream = useCallback(
    (stream) => {
      setVideoSettings(() => {
        return stream.getVideoTracks()[0].getSettings();
      });
      videoElement.current.srcObject = stream;
      if (onCaptureStream) {
        onCaptureStream(canvasElement.current.captureStream(fps));
      }
    },
    [fps, onCaptureStream]
  );

  /**
   * A callback function that is invoked before loading the MediaStream.
   *
   * @function
   */
  const handleOnLoadingVideoStream = useCallback(() => {
    setHasLoadedVideoData(false);
  }, []);

  useVideoStream(
    handleOnLoadedVideoStream,
    handleOnLoadingVideoStream,
    videoTrackConstraints
  );

  useInterval(fps, () => {
    if (!hasLoadedVideoData) {
      return;
    }

    if (onRenderBackground) {
      onRenderBackground(canvasElement, videoElement);
    } else {
      renderUnprocessedVideo(canvasElement, videoElement);
    }

    if (onRenderForeground) {
      onRenderForeground(canvasElement, videoElement);
    }
  });

  return (
    <div
      style={{
        alignItems: "center",
        display: "flex",
        justifyContent: "center",
        overflow: "hidden",
        ...style,
      }}
    >
      <video
        autoPlay
        aria-hidden
        height={height}
        onLoadedData={() => {
          setHasLoadedVideoData(true);
        }}
        muted
        ref={videoElement}
        style={{
          opacity: 0,
          position: "fixed",
        }}
        width={width}
      />
      <canvas
        height={`${height}`}
        ref={canvasElement}
        style={{ border: "1px dotted magenta" }}
        width={`${width}`}
      />
    </div>
  );
}

VideoCompositor.propTypes = {
  /** The target number of renders per second. */
  fps: PropTypes.number,

  /** A callback function that handles a MediaStream. */
  onCaptureStream: PropTypes.func,

  /** A callback function that handles a canvas ref and a video ref. */
  onRenderBackground: PropTypes.func,

  /** A callback function that handles a canvas ref and a video ref. */
  onRenderForeground: PropTypes.func,

  /** CSS styles to apply to this component. */
  style: PropTypes.object,

  /** A set of capabilities and the value or values each can take on. */
  videoTrackConstraints: PropTypes.object,
};