MSE(Media Source Extensions) 구현하기

Woody·2024년 8월 26일
0

react

목록 보기
3/6

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는 모던 브라우저에서 지원되지만, 일부 구형 브라우저에서는 사용할 수 없습니다.

구현 복잡도 증가: MSE를 활용하려면 미디어 데이터 처리, 버퍼 관리 등을 직접 구현해야 하므로 구현 복잡도가 증가할 수 있습니다.

React 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>
  );
};

initMediaSource 함수

  • MediaSource 객체를 생성하고 비디오 요소의 src에 할당합니다.
  • MediaSource가 'sourceopen' 이벤트를 발생시키면 지원 가능한 코덱 정보를 서버에 전송합니다.
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);
  }
};

initSourceBuffer 함수

서버에서 전송한 코덱 정보를 기반으로 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;
    }
  }
};

handleData 함수

  • 웹소켓을 통해 수신한 비디오 데이터를 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]);

useEffect 훅

  • 컴포넌트가 마운트될 때 웹소켓 연결을 설정하고 '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/

profile
프론트엔드 개발자로 살아가기

0개의 댓글