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 접속
설정 후 방송 시작.
localhost:8080 접속
댓글
댓글 쓰기