MSE(Media Source Extensions)는 웹 브라우저에서 동적으로 미디어 콘텐츠를 생성하고 제어할 수 있는 JavaScript API입니다.
기존에는 video, audio 태그의 src 속성에 미디어 URL을 지정하는 방식으로 동영상을 재생했는데요. MSE를 사용하면 JavaScript로 미디어 데이터를 동적으로 제어할 수 있습니다.
MSE의 핵심 개념은 MediaSource 객체입니다. 이 객체는 미디어 데이터의 소스로 기능하며, 하나 이상의 SourceBuffer를 가질 수 있어요. SourceBuffer는 미디어 세그먼트를 받아 디코딩하고 재생할 수 있는 상태로 만듭니다.
브라우저는 MediaSource 객체를 통해 제공되는 미디어 데이터를 실제 미디어 요소(video, audio)에 제공하죠. 따라서 MSE를 사용하면 JavaScript로 미디어 데이터를 동적으로 제어하고, 필요에 따라 실시간으로 미디어를 제공할 수 있습니다.
장점
적응형 스트리밍 가능: MSE를 사용하면 네트워크 상태에 따라 동적으로 비디오 품질을 조절할 수 있어 사용자에게 최적의 시청 경험을 제공할 수 있습니다.
실시간 스트리밍 지원: 웹소켓 등을 통해 실시간으로 전송되는 비디오 데이터를 MSE로 처리할 수 있어 라이브 스트리밍 구현이 가능합니다.
유연한 미디어 제어: 개발자가 직접 미디어 버퍼를 제어할 수 있어 커스텀 플레이어 구현, 광고 삽입, 자막 처리 등 다양한 기능을 유연하게 구현할 수 있습니다.
단점
브라우저 호환성: MSE는 모던 브라우저에서 지원되지만, 일부 구형 브라우저에서는 사용할 수 없습니다.
구현 복잡도 증가: MSE를 활용하려면 미디어 데이터 처리, 버퍼 관리 등을 직접 구현해야 하므로 구현 복잡도가 증가할 수 있습니다.
const VideoRTC = ({ src }: VideoRTCProps) => {
const videoRef = useRef<HTMLVideoElement>(null);
const webSocketRef = useRef<WebSocket | null>(null);
const mediaSourceRef = useRef<MediaSource | null>(null);
const sourceBufferRef = useRef<SourceBuffer | null>(null);
const bufRef = useRef<Uint8Array>(new Uint8Array(2 * 1024 * 1024));
const bufLenRef = useRef(0);
// ...
const initMediaSource = () => {
if (!videoRef.current) return;
if (window.MediaSource) {
const mediaSource = new MediaSource();
mediaSourceRef.current = mediaSource;
videoRef.current.src = URL.createObjectURL(mediaSource);
mediaSource.addEventListener(
'sourceopen',
() => {
URL.revokeObjectURL(videoRef.current!.src);
send({ type: 'mse', value: getCodecs() });
},
{ once: true }
);
} else {
console.error('MediaSource API is not supported.');
}
};
// ...
return (
<div>
<video
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
ref={videoRef}
autoPlay
controls
muted
>
<track kind="captions" src="" srcLang="en" label="English" />
</video>
</div>
);
};
const initSourceBuffer = (codec: string) => {
const mediaSource = mediaSourceRef.current;
if (!mediaSource) return;
try {
const sourceBuffer = mediaSource.addSourceBuffer(codec);
sourceBuffer.mode = 'segments';
sourceBuffer.appendWindowStart = 0;
sourceBuffer.appendWindowEnd = Infinity;
sourceBuffer.addEventListener('updateend', () => {
if (sourceBuffer.updating) return;
if (bufLenRef.current > 0) {
try {
const data = bufRef.current.slice(0, bufLenRef.current);
bufLenRef.current = 0;
sourceBuffer.appendBuffer(data);
} catch (e) {
console.error('Error appending buffered data:', e);
}
}
});
sourceBufferRef.current = sourceBuffer;
} catch (e) {
console.error('Error initializing SourceBuffer:', e);
}
};
서버에서 전송한 코덱 정보를 기반으로 SourceBuffer를 생성하고 MediaSource에 추가합니다.
SourceBuffer의 'updateend' 이벤트 핸들러를 등록해 데이터 추가가 완료되면 다음 데이터를 처리할 수 있도록 합니다.
const handleData = (data: ArrayBuffer) => {
if (sourceBufferRef.current) {
const newData = new Uint8Array(data);
try {
if (sourceBufferRef.current.updating || bufLenRef.current > 0) {
const newBuffer = new Uint8Array(bufLenRef.current + newData.byteLength);
newBuffer.set(bufRef.current.subarray(0, bufLenRef.current), 0);
newBuffer.set(newData, bufLenRef.current);
bufRef.current = newBuffer;
bufLenRef.current += newData.byteLength;
} else {
sourceBufferRef.current.appendBuffer(newData);
}
} catch (e) {
console.error('Error appending buffer:', e);
const newBuffer = new Uint8Array(bufLenRef.current + newData.byteLength);
newBuffer.set(bufRef.current.subarray(0, bufLenRef.current), 0);
newBuffer.set(newData, bufLenRef.current);
bufRef.current = newBuffer;
bufLenRef.current += newData.byteLength;
}
}
};
웹소켓을 통해 수신한 비디오 데이터를 SourceBuffer에 추가합니다.
SourceBuffer가 아직 이전 데이터를 처리 중인 경우 버퍼(bufRef)에 데이터를 임시 저장합니다.
useEffect(() => {
const connectWebSocket = () => {
const webSocket = new WebSocket(src);
webSocket.binaryType = 'arraybuffer';
webSocket.onopen = () => {
initMediaSource();
};
webSocket.onmessage = (ev: MessageEvent) => {
if (typeof ev.data === 'string') {
try {
const message = JSON.parse(ev.data);
if (message.type === 'mse') {
initSourceBuffer(message.value);
}
} catch (e) {
console.error('Error parsing JSON message:', e);
}
} else if (ev.data instanceof ArrayBuffer) {
handleData(ev.data);
}
};
webSocketRef.current = webSocket;
};
connectWebSocket();
// ...
return () => {
if (webSocketRef.current) {
webSocketRef.current.close();
webSocketRef.current = null;
}
// ...
};
}, [src]);
컴포넌트가 마운트될 때 웹소켓 연결을 설정하고 'onopen', 'onmessage' 이벤트 핸들러를 등록합니다.
'onmessage'에서는 서버에서 전송한 메시지 유형에 따라 initSourceBuffer 또는 handleData 함수를 호출합니다.
컴포넌트가 언마운트될 때는 웹소켓 연결을 해제하고 생성한 MediaSource와 SourceBuffer를 정리합니다.
'use client';
import { useEffect, useRef } from 'react';
interface VideoRTCProps {
src: string;
}
const CODECS: string[] = [
'avc1.640029', // H.264 high 4.1 (Chromecast 1st and 2nd Gen)
'avc1.64002A', // H.264 high 4.2 (Chromecast 3rd Gen)
'avc1.640033', // H.264 high 5.1 (Chromecast with Google TV)
'hvc1.1.6.L153.B0', // H.265 main 5.1 (Chromecast Ultra)
'mp4a.40.2', // AAC LC
'mp4a.40.5', // AAC HE
'flac', // FLAC (PCM compatible)
'opus', // OPUS Chrome, Firefox
];
const VideoRTC = ({ src }: VideoRTCProps) => {
const videoRef = useRef<HTMLVideoElement>(null);
const webSocketRef = useRef<WebSocket | null>(null);
const mediaSourceRef = useRef<MediaSource | null>(null);
const sourceBufferRef = useRef<SourceBuffer | null>(null);
const bufRef = useRef<Uint8Array>(new Uint8Array(2 * 1024 * 1024));
const bufLenRef = useRef(0);
const send = (value: object) => {
if (webSocketRef.current) {
webSocketRef.current.send(JSON.stringify(value));
}
};
const getCodecs = () => {
return CODECS.filter((codec) =>
MediaSource.isTypeSupported(`video/mp4; codecs="${codec}"`)
).join();
};
const initSourceBuffer = (codec: string) => {
const mediaSource = mediaSourceRef.current;
if (!mediaSource) return;
try {
const sourceBuffer = mediaSource.addSourceBuffer(codec);
sourceBuffer.mode = 'segments';
sourceBuffer.appendWindowStart = 0;
sourceBuffer.appendWindowEnd = Infinity;
sourceBuffer.addEventListener('updateend', () => {
if (sourceBuffer.updating) return;
if (bufLenRef.current > 0) {
try {
const data = bufRef.current.slice(0, bufLenRef.current);
bufLenRef.current = 0; // ref로 관리되는 bufLen 초기화
sourceBuffer.appendBuffer(data);
} catch (e) {
console.error('Error appending buffered data:', e);
}
}
});
sourceBufferRef.current = sourceBuffer;
} catch (e) {
console.error('Error initializing SourceBuffer:', e);
}
};
const handleData = (data: ArrayBuffer) => {
if (sourceBufferRef.current) {
const newData = new Uint8Array(data);
try {
if (sourceBufferRef.current.updating || bufLenRef.current > 0) {
const newBuffer = new Uint8Array(
bufLenRef.current + newData.byteLength
);
newBuffer.set(bufRef.current.subarray(0, bufLenRef.current), 0);
newBuffer.set(newData, bufLenRef.current);
bufRef.current = newBuffer;
bufLenRef.current += newData.byteLength; // ref로 관리되는 bufLen 업데이트
} else {
sourceBufferRef.current.appendBuffer(newData);
}
} catch (e) {
console.error('Error appending buffer:', e);
const newBuffer = new Uint8Array(
bufLenRef.current + newData.byteLength
);
newBuffer.set(bufRef.current.subarray(0, bufLenRef.current), 0);
newBuffer.set(newData, bufLenRef.current);
bufRef.current = newBuffer;
bufLenRef.current += newData.byteLength; // ref로 관리되는 bufLen 업데이트
}
}
};
const initMediaSource = () => {
if (!videoRef.current) return;
if (window.MediaSource) {
const mediaSource = new MediaSource();
mediaSourceRef.current = mediaSource;
videoRef.current.src = URL.createObjectURL(mediaSource);
mediaSource.addEventListener(
'sourceopen',
() => {
URL.revokeObjectURL(videoRef.current!.src);
send({ type: 'mse', value: getCodecs() });
},
{ once: true }
);
} else {
console.error('MediaSource API is not supported.');
}
};
useEffect(() => {
const connectWebSocket = () => {
const webSocket = new WebSocket(src);
webSocket.binaryType = 'arraybuffer';
webSocket.onopen = () => {
initMediaSource();
};
webSocket.onmessage = (ev: MessageEvent) => {
if (typeof ev.data === 'string') {
try {
const message = JSON.parse(ev.data);
if (message.type === 'mse') {
initSourceBuffer(message.value);
}
} catch (e) {
console.error('Error parsing JSON message:', e);
}
} else if (ev.data instanceof ArrayBuffer) {
handleData(ev.data);
}
};
webSocketRef.current = webSocket;
};
connectWebSocket();
return () => {
if (webSocketRef.current) {
webSocketRef.current.close();
webSocketRef.current = null;
}
if (videoRef.current) {
videoRef.current.src = '';
videoRef.current.srcObject = null;
}
if (mediaSourceRef.current) {
if (
sourceBufferRef.current &&
mediaSourceRef.current.sourceBuffers.length > 0
) {
try {
mediaSourceRef.current.removeSourceBuffer(sourceBufferRef.current);
} catch (e) {
console.error('Error removing SourceBuffer:', e);
}
}
if (mediaSourceRef.current.readyState === 'open') {
try {
mediaSourceRef.current.endOfStream();
} catch (e) {
console.error('Error ending MediaSource stream:', e);
}
}
mediaSourceRef.current = null;
}
};
}, [src]);
return (
<div>
<video
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
ref={videoRef}
autoPlay
controls
muted
>
<track kind="captions" src="" srcLang="en" label="English" />
</video>
</div>
);
};
export default VideoRTC;
이렇게 MSE와 웹소켓을 활용하면 실시간으로 전송되는 비디오 데이터를 웹 브라우저에서 재생할 수 있습니다.
물론 실제 프로덕션 환경에서 사용하려면 에러 처리, 버퍼 관리, 재연결 로직 등 고려해야 할 사항이 더 있겠죠.
해당 부분은 필요하다면 코드에 추가할 수 있겠습니다.
https://ui.toast.com/posts/ko_20170915
https://www.w3.org/TR/media-source/