(거의) 브라우저에서 스트리밍하는 쓸모없는 웹캠. 미디어 스트림 및 웹소켓

이 기사에서는 Adobe Flash Player와 같은 타사 브라우저 플러그인을 사용하지 않고 웹소켓을 통해 비디오를 스트리밍하려는 시도를 공유하고 싶습니다. 그 결과를 알아보려면 계속 읽어보세요.

Adobe Flash(이전의 Macromedia Flash)는 웹 브라우저에서 실행되는 응용 프로그램을 만들기 위한 플랫폼입니다. Media Stream API가 도입되기 전에는 웹캠에서 비디오와 음성을 스트리밍하고 브라우저에서 다양한 종류의 회의와 채팅을 생성하기 위한 사실상 유일한 플랫폼이었습니다. 미디어 정보 전송을 위한 프로토콜인 RTMP(실시간 메시징 프로토콜)는 실제로 오랫동안 폐쇄되어 있었습니다. 즉, 스트리밍 서비스를 강화하려면 친절하게도 Adobe 자체 소프트웨어인 Adobe Media Server(AMS)를 사용하십시오.

2012년 얼마 후 Adobe는 대중에게 "포기하고 내뱉었습니다". 사양 오류가 포함되어 있고 본질적으로 불완전한 RTMP 프로토콜입니다. 그 무렵 개발자들은 이 프로토콜을 자체적으로 구현하기 시작했고 Wowza 서버가 나타났습니다. 2011년 어도비는 RTMP 관련 특허 불법 사용 혐의로 와우자를 상대로 소송을 제기했고, 4년 만에 갈등이 원만하게 해결됐다.

Adobe Flash 플랫폼은 20년이 넘었으며 그 동안 많은 심각한 취약점이 발견되었습니다. 약속 한 2020년까지 종료되어 스트리밍 서비스에 대한 대안이 거의 남지 않았습니다.

내 프로젝트에서는 즉시 브라우저에서 Flash 사용을 완전히 포기하기로 결정했습니다. 위에서 주된 이유를 밝혔는데, Flash도 모바일 플랫폼에서 전혀 지원되지 않으며 Windows(와인 에뮬레이터) 개발을 위해 Adobe Flash를 배포하고 싶지 않았습니다. 그래서 저는 JavaScript로 클라이언트를 작성하기 시작했습니다. 이것은 단지 프로토타입일 뿐입니다. 나중에 스트리밍이 p2p를 기반으로 훨씬 더 효율적으로 수행될 수 있다는 것을 알게 되었기 때문에 저에게는 피어-서버-피어가 될 것이지만 아직 준비되지 않았기 때문에 나중에 더 자세히 설명하겠습니다.

시작하려면 실제 웹소켓 서버가 필요합니다. 저는 melody go 패키지를 기반으로 가장 간단한 것을 만들었습니다:

서버 코드

package main

import (
	"errors"
	"github.com/go-chi/chi"
	"gopkg.in/olahol/melody.v1"
	"log"
	"net/http"
	"time"
)

func main() {
	r := chi.NewRouter()
	m := melody.New()

	m.Config.MaxMessageSize = 204800

	r.Get("/", func(w http.ResponseWriter, r *http.Request) {
		http.ServeFile(w, r, "public/index.html")
	})
	r.Get("/ws", func(w http.ResponseWriter, r *http.Request) {
		m.HandleRequest(w, r)
	})

         // Бродкастим видео поток 
	m.HandleMessageBinary(func(s *melody.Session, msg []byte) {
		m.BroadcastBinary(msg)
	})

	log.Println("Starting server...")

	http.ListenAndServe(":3000", r)
}

클라이언트(스트리밍 측)에서 먼저 카메라에 액세스해야 합니다. 이는 다음을 통해 이루어집니다. 미디어스트림 API.

우리는 다음을 통해 카메라/마이크에 대한 액세스(권한)를 얻습니다. 미디어 장치 API. 이 API는 메소드를 제공합니다 MediaDevices.getUserMedia(), 팝업이 표시됩니다. 사용자에게 카메라 및/또는 마이크에 대한 액세스 권한을 요청하는 창. 모든 실험은 Google Chrome에서 수행했지만 Firefox에서도 모든 것이 동일하게 작동할 것이라고 생각합니다.

다음으로, getUserMedia()는 비디오-오디오 데이터 스트림인 MediaStream 객체를 전달하는 Promise를 반환합니다. 이 객체를 video 요소의 src 속성에 할당합니다. 암호:

방송측

<style>
  #videoObjectHtml5ApiServer { width: 320px; height: 240px; background: #666; }
</style>
</head>
<body>
<!-- Здесь в этом "окошечке" клиент будет видеть себя -->
<video autoplay id="videoObjectHtml5ApiServer"></video>

<script type="application/javascript">
  var
        video = document.getElementById('videoObjectHtml5ApiServer');

// если доступен MediaDevices API, пытаемся получить доступ к камере (можно еще и к микрофону)
// getUserMedia вернет обещание, на которое подписываемся и полученный видеопоток в колбеке направляем в video объект на странице

if (navigator.mediaDevices.getUserMedia) {
        navigator.mediaDevices.getUserMedia({video: true}).then(function (stream) {
          // видео поток привязываем к video тегу, чтобы клиент мог видеть себя и контролировать 
          video.srcObject = stream;
        });
}
</script>

소켓을 통해 비디오 스트림을 브로드캐스트하려면 어딘가에서 인코딩하고 버퍼링한 후 부분적으로 전송해야 합니다. 원시 비디오 스트림은 웹소켓을 통해 전송할 수 없습니다. 여기가 우리를 도와주는 곳이에요 미디어 레코더 API. 이 API를 사용하면 스트림을 인코딩하고 조각으로 나눌 수 있습니다. 네트워크를 통해 더 적은 바이트를 보내기 위해 비디오 스트림을 압축하는 인코딩을 수행합니다. 조각으로 나눈 후 각 조각을 웹 소켓으로 보낼 수 있습니다. 암호:

비디오 스트림을 인코딩하고 여러 부분으로 나눕니다.

<style>
  #videoObjectHtml5ApiServer { width: 320px; height: 240px; background: #666; }
</style>
</head>
<body>
<!-- Здесь в этом "окошечке" клиент будет видеть себя -->
<video autoplay id="videoObjectHtml5ApiServer"></video>

<script type="application/javascript">
  var
        video = document.getElementById('videoObjectHtml5ApiServer');

// если доступен MediaDevices API, пытаемся получить доступ к камере (можно еще и к микрофону)
// getUserMedia вернет обещание, на которое подписываемся и полученный видеопоток в колбеке направляем в video объект на странице

if (navigator.mediaDevices.getUserMedia) {
        navigator.mediaDevices.getUserMedia({video: true}).then(function (stream) {
          // видео поток привязываем к video тегу, чтобы клиент мог видеть себя и контролировать 
          video.srcObject = s;
          var
            recorderOptions = {
                mimeType: 'video/webm; codecs=vp8' // будем кодировать видеопоток в формат webm кодеком vp8
              },
              mediaRecorder = new MediaRecorder(s, recorderOptions ); // объект MediaRecorder

               mediaRecorder.ondataavailable = function(e) {
                if (e.data && e.data.size > 0) {
                  // получаем кусочек видеопотока в e.data
                }
            }

            mediaRecorder.start(100); // делит поток на кусочки по 100 мс каждый

        });
}
</script>

이제 웹소켓을 통한 전송을 추가해 보겠습니다. 놀랍게도 이를 위해 필요한 것은 객체뿐입니다. 웹 소켓. send 및 close 두 가지 방법만 있습니다. 이름은 스스로를 말합니다. 추가된 코드:

비디오 스트림을 서버로 전송합니다

<style>
  #videoObjectHtml5ApiServer { width: 320px; height: 240px; background: #666; }
</style>
</head>
<body>
<!-- Здесь в этом "окошечке" клиент будет видеть себя -->
<video autoplay id="videoObjectHtml5ApiServer"></video>

<script type="application/javascript">
  var
        video = document.getElementById('videoObjectHtml5ApiServer');

// если доступен MediaDevices API, пытаемся получить доступ к камере (можно еще и к микрофону)
// getUserMedia вернет обещание, на которое подписываемся и полученный видеопоток в колбеке направляем в video объект на странице

if (navigator.mediaDevices.getUserMedia) {
        navigator.mediaDevices.getUserMedia({video: true}).then(function (stream) {
          // видео поток привязываем к video тегу, чтобы клиент мог видеть себя и контролировать 
          video.srcObject = s;
          var
            recorderOptions = {
                mimeType: 'video/webm; codecs=vp8' // будем кодировать видеопоток в формат webm кодеком vp8
              },
              mediaRecorder = new MediaRecorder(s, recorderOptions ), // объект MediaRecorder
              socket = new WebSocket('ws://127.0.0.1:3000/ws');

               mediaRecorder.ondataavailable = function(e) {
                if (e.data && e.data.size > 0) {
                  // получаем кусочек видеопотока в e.data
                 socket.send(e.data);
                }
            }

            mediaRecorder.start(100); // делит поток на кусочки по 100 мс каждый

        }).catch(function (err) { console.log(err); });
}
</script>

방송측 준비 완료! 이제 비디오 스트림을 수신하여 클라이언트에 표시해 보겠습니다. 이를 위해 무엇이 필요합니까? 첫째, 물론 소켓 연결입니다. WebSocket 객체에 "리스너"를 연결하고 'message' 이벤트를 구독합니다. 바이너리 데이터를 수신하면 서버는 이를 구독자, 즉 클라이언트에게 브로드캐스팅합니다. 이 경우 'message' 이벤트의 "리스너"와 연관된 콜백 함수가 클라이언트에서 트리거되고 객체 자체가 함수 인수(vp8에 의해 인코딩된 비디오 스트림의 일부)로 전달됩니다.

우리는 비디오 스트림을 받아들입니다

<style>
  #videoObjectHtml5ApiServer { width: 320px; height: 240px; background: #666; }
</style>
</head>
<body>
<!-- Здесь в этом "окошечке" клиент будет видеть тебя -->
<video autoplay id="videoObjectHtml5ApiServer"></video>

<script type="application/javascript">
  var
        video = document.getElementById('videoObjectHtml5ApiServer'),
         socket = new WebSocket('ws://127.0.0.1:3000/ws'), 
         arrayOfBlobs = [];

         socket.addEventListener('message', function (event) {
                // "кладем" полученный кусочек в массив 
                arrayOfBlobs.push(event.data);
                // здесь будем читать кусочки
                readChunk();
            });
</script>

오랫동안 수신된 조각을 재생을 위해 비디오 요소로 즉시 보내는 것이 불가능한 이유를 이해하려고 노력했지만 이것이 불가능하다는 것이 밝혀졌습니다. 물론 먼저 해당 조각을 바인딩된 특수 버퍼에 넣어야 합니다. 비디오 요소를 추가한 후에만 비디오 스트림 재생이 시작됩니다. 이를 위해서는 당신이 필요합니다 미디어소스 API и 파일 리더 API.

MediaSource는 미디어 재생 객체와 이 미디어 스트림의 소스 사이에서 일종의 중개자 역할을 합니다. MediaSource 개체에는 비디오/오디오 스트림 소스에 대한 플러그형 버퍼가 포함되어 있습니다. 한 가지 기능은 버퍼가 Uint8 데이터만 보유할 수 있으므로 이러한 버퍼를 생성하려면 FileReader가 필요하다는 것입니다. 코드를 보면 더 명확해질 것입니다.

비디오 스트림 재생

<style>
  #videoObjectHtml5ApiServer { width: 320px; height: 240px; background: #666; }
</style>
</head>
<body>
<!-- Здесь в этом "окошечке" клиент будет видеть тебя -->
<video autoplay id="videoObjectHtml5ApiServer"></video>

<script type="application/javascript">
  var
        video = document.getElementById('videoObjectHtml5ApiServer'),
         socket = new WebSocket('ws://127.0.0.1:3000/ws'),
        mediaSource = new MediaSource(), // объект MediaSource
        vid2url = URL.createObjectURL(mediaSource), // создаем объект URL для связывания видеопотока с проигрывателем
        arrayOfBlobs = [],
        sourceBuffer = null; // буфер, пока нуль-объект

         socket.addEventListener('message', function (event) {
                // "кладем" полученный кусочек в массив 
                arrayOfBlobs.push(event.data);
                // здесь будем читать кусочки
                readChunk();
            });

         // как только MediaSource будет оповещен , что источник готов отдавать кусочки 
        // видео/аудио потока
        // создаем буфер , следует обратить внимание, что буфер должен знать в каком формате 
        // каким кодеком был закодирован поток, чтобы тем же способом прочитать видеопоток
         mediaSource.addEventListener('sourceopen', function() {
            var mediaSource = this;
            sourceBuffer = mediaSource.addSourceBuffer("video/webm; codecs="vp8"");
        });

      function readChunk() {
        var reader = new FileReader();
        reader.onload = function(e) { 
          // как только FileReader будет готов, и загрузит себе кусочек видеопотока
          // мы "прицепляем" перекодированный в Uint8Array (был Blob) кусочек в буфер, связанный
          // с проигрывателем, и проигрыватель начинает воспроизводить полученный кусочек видео/аудио
          sourceBuffer.appendBuffer(new Uint8Array(e.target.result));

          reader.onload = null;
        }
        reader.readAsArrayBuffer(arrayOfBlobs.shift());
      }
</script>

스트리밍 서비스의 프로토타입이 준비되었습니다. 가장 큰 단점은 비디오 재생이 전송 측보다 100ms 정도 지연된다는 것입니다. 이를 서버로 전송하기 전에 비디오 스트림을 분할할 때 직접 설정했습니다. 게다가 노트북으로 확인해 보니 송신측과 수신측 사이의 지연이 점차 쌓여가는 것이 확연히 드러났습니다. 이런 단점을 극복할 수 있는 방법을 찾기 시작했는데... RTCPeerConnection API를 사용하면 스트림을 여러 조각으로 나누는 등의 트릭 없이 비디오 스트림을 전송할 수 있습니다. 누적 지연은 브라우저가 전송 전에 각 부분을 webm 형식으로 다시 인코딩하기 때문인 것 같습니다. 더 이상 파고들지 않고 WebRTC를 공부하기 시작했는데, 연구 결과가 커뮤니티에 흥미를 끌면 별도의 글을 쓸 생각입니다.

출처 : habr.com

코멘트를 추가