-
Notifications
You must be signed in to change notification settings - Fork 1
카메라와 화면 공유 on off 상태에 따라 송출하는 비디오 트랙 교체하기
방송이 시작되면 CPU 사용량이 급격히 치솟는 문제가 발생.
캔버스로 비디오를 보여주기 위해 rAF(requestAnimationFrame)이 캔버스를 그리는 콜백함수를 재귀호출하고 있다. rAF는 사용자의 모니터 주사율에 따라 다르긴 하지만, 일반적으로 1초에 60회의 콜백함수를 실행한다. 그렇기 때문에 클라이언트 부하가 심한 것이라고 예상된다.
기존에는 카메라만 켜든 화면 공유만 켜든 둘 다 켜든 어떤 상황에도 캔버스를 캡처한 스트림을 송출하고 있었다. 그렇지만 사실상 이 캔버스가 필요한 상황은 카메라와 화면 공유가 둘 다 켜진 상황뿐이다.
꼭 필요할 때만 사용하도록 변경하여 CPU 부하를 줄여보고자 한다.
-
트랙 교체
Producer: WebRTC 전송을 통해 mediasoup Router로 전송되는 오디오 또는 비디오 소스를 나타낸다.
Producer에는
replaceTrack
이라는 메서드가 있다. 이 메서드를 이용하면 서버에게 알리거나 할 필요 없이 전송 중인 오디오 또는 비디오 트랙을 변경할 수 있다.https://mediasoup.org/documentation/v3/mediasoup-client/api/#producer-replaceTrack
producer.replaceTrack({ track })
방송을 담당하는 Broadcast 페이지에서 아래와 같이
isVideoEnabled
와isScreenSharing
의 상태가 변경될 때마다 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> ); }
구현 후 테스트해보다가 다음과 같은 현상이 발생하는 것을 발견했다.
- 캠만 켜져있다가 화면 공유 켜면 캠이 꺼짐
- 화면 공유만 켜져있다가 캠 키면 화면 공유가 꺼짐
원인을 찾고자 카메라와 화면 공유 상태가 변경될 때마다 비디오 트랙의 상태를 확인해봤다.
"캠on/화면공유off ⇒ 캠off/화면공유off" 상태가 되었을 때의 미디어 스트림 비디오 트랙들 상태
확인해보니 replaceTrack
으로 MediaStreamTrack을 변경하면 기존에 송출되고 있던 MediaStreamTrack은 readyState: "ended"
가 되어 버리는 것을 발견했다.
이걸 방지할 방법이 있을까 찾아봤는데, mediasoup-client 공식 문서에서 답을 얻을 수 있었다.
출처: https://mediasoup.org/documentation/v3/mediasoup-client/api/
출처: https://mediasoup.org/documentation/v3/mediasoup-client/api/
transport.produce()
를 실행할 때 stopTracks
를 false
로 설정해주거나 트랙을 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% 가량 낮아진 것을 확인할 수 있었다.
- https://mediasoup.org/documentation/v3/mediasoup-client/api
- rAF
- replaceTrack
- Mediasoup 포트 매핑 문제
- swagger 같은 응답 코드에 다양한 응답 보여주기
- Sudo가 계속 비밀번호를 요청함
- Docker 이미지가 너무 크다
- Git action에서 도커 이미지 빌드 시간을 단축시켜보자
- Docker compose를 이용해서 메모리 사용률을 줄여보자
- 방송 녹화 시 CPU 과부하 문제를 해결해보자
- Release 브랜치? 너 필요해?
- 로딩이 너무 짧아…!
- NestJS ORM으로 무엇을 사용해야 할까?
- WebRTC를 이용한 1:N 스트리밍 서비스에서 시그널링 서버가 필요할까?
- 실시간 채팅 구현: 인메모리 방식을 선택한 이유
- MySQL 아키텍처 개선: DB 의존성 분리와 서버 역할 명확화
- 브라우저 창이 최소화되면 비디오 송출이 안된다…!
- Mediasoup 기본 개념
- DLTS와 Signaling
- Tell, Don't Ask (TDA) 원칙이란
- VPC(Virtual Private Cloud) 학습 정리
- 순환참조: A 서비스 ‐ B 서비스 vs. A 서비스 ‐ B 레포지토리
- Dto 메서드 전략
- WebRTC란?
- 자바스크립트 패키지 매니저(npm, yarn, pnpm)
- shadcn/ui을 이용해 UI 개발 생산성 높이기
- React 이벤트 핸들러 네이밍(on vs handle)
- React-router-dom의 createBrowserRouter을 사용해보기
- fetch vs axios