두개의 Video 태그를 하나로 합치기

홍석주·2025년 2월 26일
0

Kwitch

목록 보기
6/6

이번에 크위치에 유저의 캠과 마이크 음성을 추가하기로 결정했다.

기능적 요구사항

  • layout을 설정해 유저가 원하는 화면을 설정할 수 있도록 한다.
  • 화면은 전환이 가능하며, 캠과 마이크는 활성화/비활성화가 가능해야 한다.
  • 유저 클라이언트에선 합성된 화면에 대해 전체화면이 가능해야 한다.

개발

스트리머 부분은 간단했다.
처음엔 전체화면에 대해 생각하지 않았기 때문에 두개의 video 태그를 생성해서 각 layout에 따라 위치와 크기를 조정하도록 했다.

for (const track of mediaStream.getVideoTracks()) {
  const producer = await createProducer({
    transport: sendTransport,
    producerOptions: {
      track,
      appData: {
        source,
      },
    },
  })
  producers.video = producer
}

두개의 Producer를 구분하는 방법은 단순히 producerOptions에 appData에 source를 "display" | "user"로 구분했다.

appData는 mediasoup Entity에 대해 개발자가 Custom한 데이터를 넣을 수 있는 부분입니다.
docs: producer.appData

문제 1: 전체화면에서 두 화면이 다 보여야 한다.

문제는 여기서 시작됬다.
두개의 Video 태그를 사용하면 어떤 화면을 전체화면 할지 결정할 수 없다는 걸 간과했다.

따라서 두개의 Video 태그를 한 곳에 묶어 전체화면을 가능하도록 했어야 했다.

const drawCanvas = () => {
	...
    const draw = () => {
      ctx.clearRect(0, 0, canvas.width, canvas.height)

      ctx.drawImage(displayVideo, 0, 0, canvas.width, canvas.height)
      ctx.drawImage(
        userVideo,
        canvas.width - userVideo.videoWidth,
        canvas.height - userVideo.videoHeight,
        userVideo.videoWidth,
        userVideo.videoHeight,
      )

      requestAnimationFrame(draw)
    }

    requestAnimationFrame(draw)
  }

내 계획은 Canvas 위에 두개의 Video를 layout에 맞게 그리고 requestFullscreen()을 통해 전체화면을 보여줄 예정이였다.

중요한점은 clearRect를 꼭 해줘야 한다는 점이다. 안그러면 사용하는 메모리가 기하급수적으로 상승한다.

문제 2: 전체화면 시에 Canvas 태그를 컨트롤 할 수 없다.

Video 태그를 전체화면 했을 땐, 정지, 음소거, 전체화면 해제 등 컨트롤 패널이 주어졌었는데, Canvas를 전체화면하면 이런 것들이 불가능했다.

기존에 화면에 구현해 놓았던 패널을 사용하면 가능하지만 전체화면에서 ESC를 누르고 사용한다는건 너무 불편해보였다.

다시 Video 태그를 사용할 방법을 알아보다가 Canvas.captureStream를 찾았다.
이는 MediaStream의 일종인 CanvasCaptureMediaStreamTrack를 반환하는데, Canvas의 실시간 비디오를 가져올 수 있는 것이였다.

따라서 canvas에서 합친 후 다시 video를 가져오게 하면 된다는 것을 알았다.

const drawCanvas = () => {
  if (
    !combineVideoRef.current ||
    !displayVideoRef.current ||
    !userVideoRef.current
  ) {
    return
  }

  const canvas = document.createElement("canvas")
  const ctx = canvas.getContext("2d")
  if (!ctx) return

  const displayVideo = displayVideoRef.current
  const userVideo = userVideoRef.current

  canvas.width = displayVideo.videoWidth
  canvas.height = displayVideo.videoHeight
  ctx.imageSmoothingEnabled = false // 성능을 위해 비활성화

  combineVideoRef.current.srcObject = canvas.captureStream(30)
  combineVideoRef.current.play().catch((error) => {
    console.error("Error playing video:", error)
  })

  const draw = () => {
    ...
  }

  requestAnimationFrame(draw)
}

Canvas를 동적으로 생성으로 생성하고 combineVideoRef에 연결된 Video 태그에 실시간으로 가져왔다.

여기서 중요한 부분이 두개가 있는데

displayVideo.onloadedmetadata = () => {
  if (userVideo.readyState >= 1) {
    drawCanvas()
  }
}

userVideo.onloadedmetadata = () => {
  if (displayVideo.readyState >= 1) {
    drawCanvas()
  }
}
  • Canvas의 width, height를 설정하는 부분에 videoWidth 함수를 사용하는 부분이 보일텐데, 이는 ref의 current가 존재한다고 바로 나오는 부분이 아니다. Video 태그의 metadata가 준비되어야 이를 잘 응답하므로 이를 잘 처리해야 한다. 나는 두개의 Video 태그가 존재하므로 둘 다 준비되었을때 Cavas를 그리도록 했다.
combineVideoRef.current.srcObject = canvas.captureStream(0)
  • captureStream의 첫번째 인자인 frameRate인데 이를 설정하지 않으면 굉장히 높게 책정되는 것 같다. Chrome tab에 마우스를 올려 Memory usage를 확인하니 바로 2GB가 넘어가면서 멈춘다. 따라서

결과

두 개의 비디오가 잘 합쳐지며 전체화면까지 문제없이 동작하는 화면을
추가적으로 layout에 대한 업데이트 처리를 하고 성능을 좀 더 테스트 한 뒤에 프로덕션에 반영할 예정이다.

진행사항은 이 이슈에서 확인이 가능하다.

profile
이거이 니 정주영이고 이병철이야

0개의 댓글

관련 채용 정보