(Почти) безполезен стрийминг на уеб камера от браузъра. Media Stream и Websockets

В тази статия искам да споделя своите опити за стрийминг на видео чрез websockets без използване на външни браузърни плъгини като Adobe Flash Player. Какво успях да постигна, четете по-нататък.

Adobe Flash — бивш Macromedia Flash, е платформа за изработка на приложения, работещи в уеб браузъра. Преди внедряването на Media Stream API, това беше практически единствената платформа за стрийминг на видео и глас от уеб камера, а също така за създаване на различни конференции и чатове в браузъра. Протоколът за предаване на медийна информация RTMP (Real Time Messaging Protocol) беше фактически затворен дълго време, което означаваше: ако искаш да стартираш своя стрийминг-сервис, трябва да използваш софтуера на Adobe — Adobe Media Server (AMS).

След известно време през 2012 г. Adobe „предаде“ и пусна на съда публиката спецификацията на протокола RTMP, която съдържаше грешки и по същество не беше пълна. До този момент разработчиците започнаха да правят свои реализации на този протокол и така се появи сървърът Wowza. През 2011 г. Adobe заведе дело срещу Wowza за незаконно използване на патенти, свързани с RTMP, и след 4 години конфликтът се разреши мирно.

Adobe Flash платформата е на повече от 20 години, през което време бяха открити множество критични уязвимости, а поддръжката обещаха да бъде спряна до 2020 година, така че алтернативите за стрийминг услуга не остават толкова много.

За своя проект веднага реших напълно да се откажа от използването на Flash в браузъра. Основната причина, която посочих по-горе, е, че Flash изобщо не се поддържа на мобилни платформи, а и да развивам Adobe Flash за разработка на Windows (с емулатора Wine) изобщо не ми се искаше. Затова започнах да пиша клиент на JavaScript. Това ще бъде само прототип, тъй като по-късно разбрах, че стриймингът може да бъде направен много по-ефективно на база p2p, само че при мен ще бъде peer — server — peers, но за това в друг случай, тъй като това все още не е готово.

За начало на работа ни е необходим собствено websockets-сървър. Направих най-простия, базиран на go-пакета melody:

Код на сървърната част

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("Стартиране на сървъра...")

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

На клиента (страната, която излъчва) първо е нужно да получим достъп до камерата. Това става чрез MediaStream API.

Получаваме достъп (разрешение) до камерата/микрофона чрез Media Devices API. Това API предлага метод MediaDevices.getUserMedia(), който показва изскачащ прозорец, питащ потребителя за разрешение за достъп до камерата и/или микрофона. Бих искал да отбележа, че всички експерименти проведох в Google Chrome, но мисля, че в Firefox всичко ще работи по подобен начин.

След това getUserMedia() връща Promise, в който предава MediaStream обект — поток от видео- и аудиоданни. Този обект присвояваме на 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>

За да излъчвате видео поток през сокети, е необходимо да го кодиране, буферирате и предавате на части. Не можем да предадем суровия видео поток през websockets. Тук на помощ идва MediaRecorder API. Този API позволява кодиране и разбиване на потока на части. Кодиране правя за компресия на видео потока, за да изпращам по-малко байтове в мрежата. След като го разбия на части, мога да изпратя всяка част в websocket. Код:

Кодираме видео потока, разцепваме го на части

<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>

Сега добавяме предаване през websockets. Както не е изненада, за това е нужен единствено обектът WebSocket. Има само два метода 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 поставяме 'слушател' (listener), подписваме се за събитието ‘message’. Получавайки част от бинарни данни, нашият сървър я бродкаства на своите абонати, тоест клиентите. Клиентът активира callback функцията, свързана със 'слушателя' на събитието ‘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>

Дълго време се опитвах да разбера защо не мога да изпратя получените фрагменти директно за възпроизвеждане на елемента video, но се оказа, че това не може да се направи; първо трябва да поставя фрагмента в специален буфер, свързан с елемента video, и само тогава видеопотокът ще започне да се възпроизвежда. За това ще е необходимо MediaSource API и FileReader 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>

Прототипът на стрийминг услугата е готов. Основният недостатък е, че видеовъзпроизвеждането ще изостава от предавателната страна с 100 мс, което го зададохме сами при разчупването на видеопотока преди изпращането на сървъра. Освен това, когато проверявах на лаптопа си, забелязах, че постепенно се натрупва закъснение между предавателната и приемата страна, което беше очевидно. Започнах да търся начини да преодолея този недостатък и… попаднах на RTCPeerConnection API, което позволява предаване на видеопоток без маневри като разчупване на потока на фрагменти. Натрупаното закъснение, мисля, е заради това, че в браузъра преди предаването се извършва повторно кодиране на всеки фрагмент в формат webm. Не се заинтересувах повече, а започнах да изучавам WebRTC; резултатите от моите проучвания, мисля, ще напиша в отделна статия, ако я сметна за интересна за общността.

Източник: habr.com

Купете надежден хостинг за сайтове със защита от DDoS, VPS и VDS сървъри 🔥 Купете надежден хостинг за сайтове със защита от DDoS, VPS и VDS сървъри | ProHoster