Skip to content

카메라와 화면 공유 on off 상태에 따라 송출하는 비디오 트랙 교체하기

Jiyeon Baek edited this page Dec 6, 2024 · 1 revision

🚨문제 상황

문제

방송이 시작되면 CPU 사용량이 급격히 치솟는 문제가 발생.

추측한 원인

캔버스로 비디오를 보여주기 위해 rAF(requestAnimationFrame)이 캔버스를 그리는 콜백함수를 재귀호출하고 있다. rAF는 사용자의 모니터 주사율에 따라 다르긴 하지만, 일반적으로 1초에 60회의 콜백함수를 실행한다. 그렇기 때문에 클라이언트 부하가 심한 것이라고 예상된다.

🌟해결

카메라와 화면 공유 on/off가 변경될 때마다 송출되는 비디오 트랙 변경

이렇게 했던 이유

기존에는 카메라만 켜든 화면 공유만 켜든 둘 다 켜든 어떤 상황에도 캔버스를 캡처한 스트림을 송출하고 있었다. 그렇지만 사실상 이 캔버스가 필요한 상황은 카메라와 화면 공유가 둘 다 켜진 상황뿐이다.

꼭 필요할 때만 사용하도록 변경하여 CPU 부하를 줄여보고자 한다.

시도

  • 트랙 교체

    Producer: WebRTC 전송을 통해 mediasoup Router로 전송되는 오디오 또는 비디오 소스를 나타낸다.

    Producer에는 replaceTrack이라는 메서드가 있다. 이 메서드를 이용하면 서버에게 알리거나 할 필요 없이 전송 중인 오디오 또는 비디오 트랙을 변경할 수 있다.

    https://mediasoup.org/documentation/v3/mediasoup-client/api/#producer-replaceTrack

    producer.replaceTrack({ track })

    방송을 담당하는 Broadcast 페이지에서 아래와 같이 isVideoEnabledisScreenSharing의 상태가 변경될 때마다 producer에서 전송 중인 트랙을 바꿔주도록 구현했다.

    // Broadcast/index.tsx
    function Broadcast(){
    // ...
    	useEffect(() => {
        changeTrack();
      }, [isVideoEnabled, isScreenSharing]);
    
      const changeTrack = async () => {
        const currentProducer = producers.get('video');
        if (!currentProducer) return;
    
        currentProducer.pause();
    
        let newTrack = null;
    
        if (isVideoEnabled && isScreenSharing) {
          newTrack = tracksRef.current.video || null;
        } else if (isVideoEnabled && !isScreenSharing) {
          newTrack = mediaStream?.getVideoTracks()[0] || null;
        } else if (!isVideoEnabled && isScreenSharing) {
          newTrack = screenStream?.getVideoTracks()[0] || null;
        }
    
        if (isVideoEnabled && mediaStream) mediaStream.getVideoTracks()[0].enabled = true;
        if (isScreenSharing && screenStream) screenStream.getVideoTracks()[0].enabled = true;
    
        await currentProducer.replaceTrack({ track: newTrack });
    
        if (newTrack) {
          currentProducer.resume();
        }
      };
      // ...
    }
  • rAF 호출 횟수 줄이기

    rAF는 캔버스에 영상이 재생될 때, 즉 카메라와 화면 공유가 모두 on일 때만 실행되도록 했다.

    그리고 카메라나 화면 공유 중 하나만 사용될 때는 각 비디오가 연결된 video Element가 보여지도록 하고 둘 다 사용될 때에만 canvas가 사용자에게 보여지도록 했다.

    // src/pages/Broadcast/BroadcastPlayer.tsx
    function BroadcastPlayer({}){
    	// ...
    	useEffect(() => {
        const canvas = canvasRef.current;
        if (!canvas) return;
    
        canvas.width = RESOLUTION_OPTIONS['high'].width;
        canvas.height = RESOLUTION_OPTIONS['high'].height;
    
        const context = canvas.getContext('2d');
        if (!context) return;
    
        context.imageSmoothingEnabled = true;
        context.imageSmoothingQuality = 'high';
    
        const draw = () => {
          context.clearRect(0, 0, canvas.width, canvas.height);
    
          if (isScreenSharing && screenShareRef.current) {
            const screenVideo = screenShareRef.current;
    
            const screenRatio = screenVideo.videoWidth / screenVideo.videoHeight;
            const canvasRatio = canvas.width / canvas.height;
            const draw = { width: canvas.width, height: canvas.height, x: 0, y: 0 };
    
            if (screenRatio > canvasRatio) {
              // 화면이 더 넓은 경우
              draw.height = canvas.width / screenRatio;
              draw.y = (canvas.height - draw.height) / 2;
            } else {
              // 화면이 더 좁은 경우
              draw.width = canvas.height * screenRatio;
              draw.x = (canvas.width - draw.width) / 2;
            }
    
            context.fillStyle = '#000000';
            context.fillRect(0, 0, canvas.width, canvas.height);
            context.drawImage(screenVideo, draw.x, draw.y, draw.width, draw.height);
    
            if (isVideoEnabled && videoRef.current) {
              const pipWidth = canvas.width / 4;
              const pipHeight = canvas.height / 4;
              const pipX = canvas.width - pipWidth;
              const pipY = canvas.height - pipHeight;
              context.drawImage(videoRef.current, pipX, pipY, pipWidth, pipHeight);
            }
          }
          animationFrameRef.current = requestAnimationFrame(draw);
        };
    
        const startDrawing = async () => {
          draw();
          tracksRef.current['video'] = canvas.captureStream(30).getVideoTracks()[0];
          videoRef.current?.play();
          screenShareRef.current?.play();
          if (!isStreamReady) setIsStreamReady(true);
        };
    
        if (isVideoEnabled && isScreenSharing && mediaStream && screenStream) {
          startDrawing();
        }
    
        return () => {
          if (animationFrameRef.current) {
            cancelAnimationFrame(animationFrameRef.current);
          }
        };
      }, [isVideoEnabled, isScreenSharing, mediaStream, screenStream, isStreamReady]);
      
    	return (
        <div className="relative w-full max-h-[310px] aspect-video">
          <video
            ref={videoRef}
            autoPlay
            muted
            playsInline
            className={`absolute top-0 left-0 w-full h-full bg-black ${isVideoEnabled ? '' : 'hidden'}`}
          />
          <video
            ref={screenShareRef}
            autoPlay
            muted
            playsInline
            className={`absolute top-0 left-0 w-full h-full bg-black ${isScreenSharing ? '' : 'hidden'}`}
          />
          <canvas
            ref={canvasRef}
            width={RESOLUTION_OPTIONS['high'].width}
            height={RESOLUTION_OPTIONS['high'].height}
            className={`absolute top-0 left-0 w-full h-full bg-black object-cover ${
              !isScreenSharing || !isVideoEnabled ? 'hidden' : ''
            }`}
          />
        </div>
      );
    }

시도 중 마주한 문제

구현 후 테스트해보다가 다음과 같은 현상이 발생하는 것을 발견했다.

  • 캠만 켜져있다가 화면 공유 켜면 캠이 꺼짐
  • 화면 공유만 켜져있다가 캠 키면 화면 공유가 꺼짐

원인을 찾고자 카메라와 화면 공유 상태가 변경될 때마다 비디오 트랙의 상태를 확인해봤다.

image

"캠on/화면공유off ⇒ 캠off/화면공유off" 상태가 되었을 때의 미디어 스트림 비디오 트랙들 상태

확인해보니 replaceTrack으로 MediaStreamTrack을 변경하면 기존에 송출되고 있던 MediaStreamTrack은 readyState: "ended"가 되어 버리는 것을 발견했다.

이걸 방지할 방법이 있을까 찾아봤는데, mediasoup-client 공식 문서에서 답을 얻을 수 있었다.

image

출처: https://mediasoup.org/documentation/v3/mediasoup-client/api/

image

출처: https://mediasoup.org/documentation/v3/mediasoup-client/api/

transport.produce()를 실행할 때 stopTracksfalse로 설정해주거나 트랙을 clone해서 클론한 트랙으로 produce하면 producer가 닫히거나 producer.replaceTrack()가 되어도 track이 ended가 되지 않을 것 같다고 생각했다.

그래서 아래와 같이 produce로 producer를 생성할 때 stopTracks: false 옵션을 넣어주었다.

// src/hooks/useProducer.ts
export const useProducer = ({
	// ...
  const createProducer = async (socket: Socket, transportInfo: TransportInfo) => {
		transport.current!.on('produce', (parameters, callback) => {
	      socket.emit(
	        'createProducer',
	        {
	          roomId,
	          transportId: transportInfo.transportId,
	          kind: parameters.kind,
	          rtpParameters: parameters.rtpParameters,
	        },
	        (response: { producerId: string }) => {
	          callback({ id: response.producerId });
	          setProducerId(response.producerId);
	        },
	      );
	    });
	
	    mediaStream.getTracks().forEach(track => {
	      const producerConfig: Record<string, unknown> = {
	        track: track,
	        stopTracks: false,
	      };
	
	      if (track.kind === 'video') {
	        producerConfig['encodings'] = ENCODING_OPTIONS;
	        producerConfig['codecOptions'] = {
	          videoGoogleStartBitrate: 1000,
	        };
	      }
	
	      transport.current!.produce(producerConfig).then(producer => {
	        setProducers(prev => new Map(prev).set(track.kind, producer));
	      });
	    });
    }
    
    //...
 }

stopTracks:false를 넣어주니 트랙이 replaceTrack 메서드로 교체된 이후에도 readyState가 live로 유지되어서 다시 트랙을 사용할 수 있었다.

결과

  • 로컬에서 비교

    카메라와 화면 공유 모두 사용(캔버스 캡처 비디오 트랙 전송)

    카메라와 화면 공유 모두 사용(캔버스 캡처 비디오 트랙 전송)👆🏻

    카메라만 사용(원본 비디오 트랙 전송)

    카메라만 사용(원본 비디오 트랙 전송)👆🏻

    화면 공유만 사용(원본 비디오 트랙 전송)

    화면 공유만 사용(원본 비디오 트랙 전송)👆🏻

    원본 비디오 트랙을 전송하는 경우에 캔버스 캡처 비디오 트랙을 전송하는 경우보다 CPU 사용률이 약 20% 낮은 것을 확인할 수 있었다.

  • 개선 전후 배포 버전 비교

    같은 카메라 화면에 대해 캔버스로 그릴 때(개선 전 배포)와 원본 비디오 트랙을 전송할 때(개선 후 배포)를 비교했다.

    수정 전

    수정 전👆🏻

    수정 후

    수정 후👆🏻

    같은 환경에서 같은 화면으로 비교해봤을 때 개선 후에 CPU 사용량이 약 20% 가량 낮아진 것을 확인할 수 있었다.

🔗참고 자료

👥 팀 강점

🧑‍💻 개발 일지

📌 ALL

📌 FE

📌 BE

💥 트러블 슈팅

📌 FE

📌 BE

🤔 고민

📚 학습 정리

📌 김광현

📌 백지연

📌 전희선

📌 한승헌

🤝 회의록

🗒️ 데일리 스크럼

💬 팀 회고


👨‍👩‍👧‍👦 소개

🌱 문화

🔨 기술 스택

⚙️ 서비스 아키텍쳐

🚧 CI/CD

🌊 Flow

💭 6주를 보내면서

Clone this wiki locally