RTMP으로 Video Streaming 구현하기 (DASH, HLS) - 2

먼저 알아야 될 사실

기존에는 브라우저 비디오 라이브러리 video.js를 사용했으나, 화질 선택 기능 구현에 어려움이 있었다.

  • 이에 따라 JavaScript로 화질 선택 버튼을 직접 구현하였다.
  • 그러나 브라우저 창 크기에 따라 화질 선택 버튼의 위치가 비정상적으로 표시되는 문제가 발생하였다.

이후 video.js 라이브러리의 기본 화질 선택 기능을 활용하려고 하였으나, HLS의 manifest.m3u8 정보를 읽고 화질에 대한 정보를 브라우저에 나타내도록 시도했지만 실패하였다. 

  • 그래서 JavaScript로 manifest 정보를 직접 파싱하여 화면에 출력하는 방식으로 방향을 변경하였다.
  • 다만 이 과정에서 JavaScript 코드가 과도하게 비대해졌고 유지보수가 어려운 스파게티 코드 형태로 작성되는 문제가 생겼다.


결론 : Shaka Player로 교체함.



추가된 기능 및 변경된 기능

  • DASH와 HLS분기 처리 기능 추가. (MPEG-DASH 추가)
  • 화질 선택 기능 추가.
  • Live 버튼 기능 추가.
  • video.js 라이브러리에서 Shaka Player 라이브러리로 교체

업데이트 될 기능

  • 광고기능 추가.
  • 채팅기능 추가.
  • Stream Key 동적 기능 추가.


index.html

<!doctype html>
<html lang="ko">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <title>Shaka Live Check</title>
  <link
    rel="stylesheet"
    href="https://cdn.jsdelivr.net/npm/shaka-player@4.15.14/dist/controls.css"
  />
  <script src="https://cdn.jsdelivr.net/npm/shaka-player@4.15.14/dist/shaka-player.ui.js"></script>
</head>
<style>
  body {
    margin: 0;
    background: #000;
    color: #fff;
    font-family: sans-serif;
  }

  .player-wrap {
    position: relative;
    width: 960px;
    max-width: 100%;
    margin: 40px auto;
    background: #000;
  }

  #videoContainer {
    position: relative;
    width: 100%;
    aspect-ratio: 16 / 9;
    background: #000;
  }

  video {
    width: 100%;
    height: 100%;
    display: block;
    background: #000;
    object-fit: contain;
  }

  .shaka-current-time {
    opacity: 1 !important;
    font-weight: 700 !important;
  }

  .shaka-current-time::before {
    content: '●';
    display: inline-block;
    margin-right: 6px;
  }
</style>
<body>
  <div class="player-wrap">
    <div id="videoContainer" data-shaka-player-container>
      <video id="video" autoplay muted playsinline data-shaka-player></video>
    </div>
  </div>

  <script>
    const MANIFESTS = {
      hls: {url: '/hls/master.m3u8', kind: 'hls'},
      dash: {url: '/dash/manifest.mpd', kind: 'dash'}
    };
   
    const POLL_MS = 1000;

   
    const video = document.getElementById('video');
    const selectedManifest = selectManifest(video);
    console.log('SELECTED_MANIFEST', selectedManifest);

    let shakaPlayer = null;
    let shakaControls = null;
    let spinnerEl = null;
    let liveButtonEl = null;
   
    let isAttached = false;
    let isLoading = false;
    let isStopped = false;

   
    async function checkStream() {
      try {
        const response = await fetch(selectedManifest.url, { cache: 'no-store' });
       
        if (!response.ok) {
          console.log("STREAM_NOT_DETECTED");
          showShakaSpinner();
          return;
        }

        const text = await response.text();
        const isValid = selectedManifest.kind === 'hls' ? text.includes('#EXTM3U') : text.includes('<MPD');
       
        if (isValid) {
          console.log("STREAM_DETECTED");
          await playStream();
        } else {
          console.log("STREAM_NOT_DETECTED");
          showShakaSpinner();
        }

      } catch (error) {
        console.error("NO_SIGNAL");
        showShakaSpinner();
      } finally {
        if (!isStopped) {
        setTimeout(checkStream, POLL_MS);
      }
    }
  }

  async function playStream() {
    if (isAttached || isLoading) return;

    try {
      isLoading = true;
      showShakaSpinner();

      await shakaPlayer.load(selectedManifest.url);

      logVariantTracks('after-load');
      logActiveVariant('after-load');
     
      const autoplayOk = await tryAutoplay(video);

      if (!autoplayOk) {
        console.warn('AUTOPLAY_BLOCKED');
        showShakaSpinner();
        return;
      }

      isAttached = true;
      hideShakaSpinner();
     
      console.log("PLAYING");
    } catch (error) {
      isAttached = false;
      console.error('PLAY_FAILED', error);
      showShakaSpinner();
    } finally {
      isLoading = false;
    }
  }

  function hasSegmentedStreamingApi() {
    return !!globalThis.MediaSource;
  }

  function isShakaSupported() {
    return  !!globalThis.shaka &&
            !!shaka.Player &&
            shaka.Player.isBrowserSupported();
  }

  function getPlaybackCapabilities(videoEl) {
    const nativeHls = videoEl.canPlayType('application/vnd.apple.mpegurl') !== '';
    const hasMSE = hasSegmentedStreamingApi();
    const shakaSupported = isShakaSupported();
    const dashPossible = hasMSE && shakaSupported;

    return {
      hasMSE,
      nativeHls,
      dashPossible,
      shakaSupported
    };
  }

  function selectManifest(videoEl) {
    const caps = getPlaybackCapabilities(videoEl);

    if (caps.dashPossible) {
      return MANIFESTS.dash;
    }

    return MANIFESTS.hls;
  }

  async function tryAutoplay(videoEl) {
    try {
      videoEl.muted = true;
      await videoEl.play();
      return true;
    } catch (e) {
      return false;
    }
  }


  function initShakaUI() {
    shaka.polyfill.installAll();

    const caps = getPlaybackCapabilities(video);
    console.log('PLAYBACK_CAPABILITIES', caps);

    const container = document.getElementById('videoContainer');

    shakaPlayer = new shaka.Player(video);
    const overlay = new shaka.ui.Overlay(shakaPlayer, container, video);
    shakaControls = overlay.getControls();

    const localization = shakaControls.getLocalization();
    localization.changeLocale(['en']);


    spinnerEl = container.querySelector('.shaka-spinner-container');
    liveButtonEl = container.querySelector('.shaka-current-time');
    updateLiveButton(false);
   

    video.addEventListener('timeupdate', () => {
      updateLiveButton(isAtLiveEdge());
    });

    shakaPlayer.addEventListener('adaptation', (event) => {
      console.log('ABR_AUTO_SWITCH', {
        oldTrack: event.oldTrack ? {
          id: event.oldTrack.id,
          width: event.oldTrack.width,
          height: event.oldTrack.height,
          bandwidth: event.oldTrack.bandwidth
        } : null,
        newTrack: event.newTrack ? {
          id: event.newTrack.id,
          width: event.newTrack.width,
          height: event.newTrack.height,
          bandwidth: event.newTrack.bandwidth
        } : null
      });
      logActiveVariant('adaptation');
    });


    shakaPlayer.addEventListener('variantchanged', () => {
      logActiveVariant('variantchanged');
    });
  }

  function updateLiveButton(isLiveEdge) {
    if (!liveButtonEl) return;

    if (isLiveEdge) {
      liveButtonEl.style.color = '#ff3b30';
    } else {
      liveButtonEl.style.color = '#6b7280';
    }
  }

  function isAtLiveEdge() {
    if (!shakaPlayer || !shakaPlayer.isLive()) return false;

    const liveEdge = shakaPlayer.seekRange().end;
    const gap = liveEdge - video.currentTime;

    return gap < 2;
  }

  function showShakaSpinner() {
    if (spinnerEl) {
      spinnerEl.classList.remove('shaka-hidden');
      spinnerEl.style.display = 'flex';
    }
  }

  function hideShakaSpinner() {
    if (spinnerEl) {
      spinnerEl.classList.add('shaka-hidden');
    }
  }

  function getActiveVariantTrack() {
    if (!shakaPlayer) return null;
    return shakaPlayer.getVariantTracks().find(track => track.active) || null;
  }

  function logVariantTracks(stage) {
    if (!shakaPlayer) return;

    const rows = shakaPlayer.getVariantTracks().map(track => ({
      stage,
      id: track.id,
      active: track.active,
      width: track.width,
      height: track.height,
      bandwidth: track.bandwidth,
      videoBandwidth: track.videoBandwidth,
      audioBandwidth: track.audioBandwidth,
      pixelAspectRatio: track.pixelAspectRatio,
      codecs: track.codecs
    }));

    console.group(`VARIANT_TRACKS ${stage}`);
    console.table(rows);
    console.groupEnd();
  }

  function logActiveVariant(stage) {
    const track = getActiveVariantTrack();
    if (!track) {
      console.log(`ACTIVE_VARIANT ${stage}`, 'no active track');
      return;
    }

    console.log(`ACTIVE_VARIANT ${stage}`, {
      id: track.id,
      width: track.width,
      height: track.height,
      bandwidth: track.bandwidth,
      videoBandwidth: track.videoBandwidth,
      audioBandwidth: track.audioBandwidth,
      pixelAspectRatio: track.pixelAspectRatio,
      codecs: track.codecs
    });
  }

 

  initShakaUI();
  showShakaSpinner();
  checkStream();
  </script>
</body>
</html>


docker-compose.yml

services:
  nginx-rtmp:
    image: alfg/nginx-rtmp:latest
    container_name: nginx-rtmp
    environment:
      RTMP_PORT: "1935"
      HTTP_PORT: "80"
    ports:
      - "1935:1935"
      - "8080:80"
    volumes:
      # nginx 설정 템플릿 마운트
      # 컨테이너 시작 시 nginx가 이 템플릿을 기준으로 실제 nginx.conf를 생성/로드함
      - ./nginx.conf:/etc/nginx/nginx.conf.template

      # nginx 기본 웹 루트(/var/www/html)
      # 정적 파일(index.html, player 페이지 등)을 서빙
      - ./www:/var/www/html

      - ./hls:/var/www/hls
      - ./dash:/var/www/dash


  ffmpeg-hls:
    image: jrottenberg/ffmpeg:6.1-alpine
    container_name: ffmpeg-hls
    depends_on:
      - nginx-rtmp
    restart: unless-stopped
    volumes:
      - ./hls:/hls
    entrypoint: ["ffmpeg"]
    command:
      - -hide_banner
      - -loglevel
      - info
      - -fflags
      - +genpts
      - -analyzeduration
      - 10M
      - -probesize
      - 10M
      - -i
      - rtmp://nginx-rtmp:1935/live/hello

      - -filter_complex
      - |
        [0:v]split=3[v720][v480][v360];
        [v720]scale=1280:720:force_original_aspect_ratio=decrease,pad=1280:720:(ow-iw)/2:(oh-ih)/2[v720out];
        [v480]scale=854:480:force_original_aspect_ratio=decrease,pad=854:480:(ow-iw)/2:(oh-ih)/2[v480out];
        [v360]scale=640:360:force_original_aspect_ratio=decrease,pad=640:360:(ow-iw)/2:(oh-ih)/2[v360out];
        [0:a]asplit=3[a720][a480][a360]

      - -map
      - "[v720out]"
      - -map
      - "[a720]"
      - -map
      - "[v480out]"
      - -map
      - "[a480]"
      - -map
      - "[v360out]"
      - -map
      - "[a360]"

      - -c:v
      - libx264
      - -preset
      - veryfast
      - -profile:v
      - main
      - -g
      - "60"
      - -keyint_min
      - "60"
      - -sc_threshold
      - "0"

      - -c:a
      - aac
      - -b:a
      - 128k
      - -ar
      - "48000"

      - -b:v:0
      - 2800k
      - -b:v:1
      - 1400k
      - -b:v:2
      - 800k

      - -f
      - hls
      - -hls_time
      - "4"
      - -hls_list_size
      - "10"
      - -hls_flags
      - independent_segments+omit_endlist+delete_segments
      - -master_pl_name
      - master.m3u8
      - -var_stream_map
      - "v:0,a:0,name:720p v:1,a:1,name:480p v:2,a:2,name:360p"
      - -hls_segment_filename
      - /hls/%v/seg_%03d.ts
      - /hls/%v/prog_index.m3u8

  ffmpeg-dash:
    image: jrottenberg/ffmpeg:6.1-alpine
    container_name: ffmpeg-dash
    depends_on:
      - nginx-rtmp
    restart: unless-stopped
    volumes:
      - ./dash:/dash
    entrypoint: ["ffmpeg"]
    command:
      - -hide_banner
      - -loglevel
      - info
      - -fflags
      - +genpts
      - -analyzeduration
      - 10M
      - -probesize
      - 10M
      - -i
      - rtmp://nginx-rtmp:1935/live/hello

      - -filter_complex
      - |
        [0:v]split=3[v720][v540][v360];
        [v720]scale=1280:720:force_original_aspect_ratio=decrease,pad=1280:720:(ow-iw)/2:(oh-ih)/2[v720out];
        [v540]scale=960:540:force_original_aspect_ratio=decrease,pad=960:540:(ow-iw)/2:(oh-ih)/2[v540out];
        [v360]scale=640:360:force_original_aspect_ratio=decrease,pad=640:360:(ow-iw)/2:(oh-ih)/2[v360out]

      - -map
      - "[v720out]"
      - -map
      - "[v540out]"
      - -map
      - "[v360out]"
      - -map
      - 0:a:0?

      - -c:v
      - libx264
      - -preset
      - veryfast
      - -profile:v
      - main
      - -g
      - "60"
      - -keyint_min
      - "60"
      - -sc_threshold
      - "0"

      - -b:v:0
      - 2800k
      - -b:v:1
      - 1400k
      - -b:v:2
      - 800k

      - -c:a
      - aac
      - -b:a
      - 128k
      - -ar
      - "48000"

      - -init_seg_name
      - rep$$RepresentationID$$/init.$$ext$$
      - -media_seg_name
      - rep$$RepresentationID$$/chunk-$$Number%05d$$.$$ext$$
     
      - -seg_duration
      - "4"
      - -use_template
      - "1"
      - -use_timeline
      - "1"
      - -window_size
      - "10"
      - -adaptation_sets
      - "id=0,streams=v id=1,streams=a"
      - -f
      - dash
      - /dash/manifest.mpd






nginx.conf

daemon off;
events { worker_connections 1024; }

rtmp {
    server {
        listen ${RTMP_PORT};
        chunk_size 4096;
       
        # OBS publish
        # EndPoint : rtmp://HOST:${RTMP_PORT}/live
        # RTMP URL EndPoint의 /live는 application 이름이며, application live {} 설정에 의해 결정된다.
        application live {
            live on;

            # RTMP -> HLS
            # RTMP ingest → (nginx 내부) HLS 패키징
            # hls on;
            # hls_path /var/www/hls;
            # hls_fragment 5;
            # hls_playlist_length 10;
           
            # FFmpeg 으로 hls 변환시 off 해야함.
            hls off;
        }
    }
}

http {
    # 응답 헤더/에러 페이지 등에 nginx 버전 노출을 줄임(보안/깔끔함)
    server_tokens off;
   
    # HTTP access log를 파일 대신 표준출력으로 보냄
    # Docker에서 로그 수집하기 좋음.
    access_log /dev/stdout combined;

    server {
        listen ${HTTP_PORT};
       
        # 정적 파일 제공 루트 디렉토리
        # index.html을 찾는 기준.
        root /var/www/html;

        location = / {
          try_files /index.html = 404;
        }

        # HLS 제공
        location /hls/ {
            alias /var/www/hls/;
            types {
                application/vnd.apple.mpegurl m3u8;
                video/mp2t ts;
            }
           
            add_header Cache-Control no-cache;
            add_header Access-Control-Allow-Origin *;
        }

        # DASH 제공
        location /dash/ {
            alias /var/www/dash/;
            types {
                application/dash+xml mpd;
                video/mp4 mp4 m4s;
            }
            add_header Cache-Control no-cache;
            add_header Access-Control-Allow-Origin *;
        }
    }
}



폴더구조

video-streaming/
├── dash
│   ├── ReadMe.md
│   ├── manifest.mpd
│   ├── rep0
│   ├── rep1
│   ├── rep2
│   └── rep3
├── docker-compose.yml
├── hls
│   ├── 360p
│   ├── 480p
│   ├── 720p
│   └── master.m3u8
├── nginx.conf
└── www
    └── index.html



실행방법

choi@root:~/video-streaming$ docker compose up -d
[+] Running 3/3
 ✔ Container nginx-rtmp   Running                                        0.0s
 ✔ Container ffmpeg-hls   Running                                        0.0s
 ✔ Container ffmpeg-dash  Running                                        0.0s
choi@root:~/video-streaming$

OBS Studio 실행
OBS Studio 설정 -> 방송 탭에서 
서버 : rtmp://localhost:1935/live
스트림 키 : hello

설정 후 방송 시작.

localhost:8080 접속

댓글

이 블로그의 인기 게시물

[보안] CA인증서, 서버인증서, 클라이언트 인증서와 Openssl

HTTP/HTTPS 프로토콜의 개념과 작동 원리 (WireShark)

TCP/UDP 통신 방식 (WireShark)