(Takmer) bezcenné streamovanie webovej kamery z prehliadača. Media Stream a Websockets

V článku sa chcem podeliť o svoje snahy o streamovanie videa cez websockets bez použitia externých prehliadačových pluginov ako Adobe Flash Player. Čo z toho vzniklo, čítajte ďalej.

Adobe Flash — predtým Macromedia Flash, je platforma na vytváranie aplikácií, ktoré fungujú vo webovom prehliadači. Pred zavedením Media Stream API to bola takmer jediná platforma na streamovanie videa a hlasu z webovej kamery, ako aj na vytváranie rôznych druhov konferencií a chatov v prehliadači. Protokol na prenos mediálnych informácií RTMP (Real Time Messaging Protocol) bol prakticky uzavretý dlhý čas, čo znamenalo: ak chcete spustiť svoj streamingový servis, musíte používať software od samotného Adobe — Adobe Media Server (AMS).

O niekoľko rokov neskôr, v roku 2012, sa Adobe „vzdal a vypľul“ na súd verejnosti špecifikáciu protokolu RTMP, ktorý obsahoval chyby a vlastne nebol úplný. V tom čase vývojári začali vytvárať svoje implementácie tohto protokolu, tak vznikol server Wowza. V roku 2011 si Adobe podal žalobu na Wowza za nelegálne používanie patentov súvisiacich s RTMP, konflikt sa po 4 rokoch urovnal dohodou.

Na platforme Adobe Flash už viac ako 20 rokov, počas tohto obdobia sa objavila množstvo kritických zraniteľností, podporu sľubovali má byť ukončená do roku 2020, takže alternatív pre streamingový servis nie je až tak veľa.

Pre svoj projekt som sa ihneď rozhodol úplne vzdať používania Flash v prehliadači. Hlavný dôvod som uviedol vyššie, okrem toho Flash sa vôbec nepodporuje na mobilných platformách a ani zavádzať Adobe Flash pre vývoj na Windows (emulátor wine) sa mi vôbec nechcelo. Preto som sa pustil do písania klienta v JavaScripte. Toto bude iba prototyp, pretože neskôr som sa dozvedel, že streamovanie môže byť oveľa efektívnejšie na základe p2p, len že to bude peer — server — peers, ale o tom inokedy, pretože to ešte nie je hotové.

Na začiatok práce potrebujeme vlastne websocket server. Urobil som najjednoduchší na základe go balíka melody:

Kód serverovej časti

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

		// Zdieľame video stream 
	m.HandleMessageBinary(func(s *melody.Session, msg []byte) {
		m.BroadcastBinary(msg)
	})

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

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

Na strane klienta (na vysielajúcej strane) je najprv potrebné získať prístup k kamere. Robí sa to cez MediaStream API.

Získavame prístup (povolenie) k kameře/mikrofónu cez Media Devices API. Toto API poskytuje metódu MediaDevices.getUserMedia(), ktorá zobrazuje vyskakovacie okno, žiadajúce používateľa o povolenie prístupu k kamere a/alebo mikrofónu. Chcel by som poznamenať, že všetky experimenty som vykonal v prehliadači Google Chrome, ale myslím si, že vo Firefoxe to bude fungovať zhruba rovnako.

Následne getUserMedia() vracia Promise, do ktorej predáva objekt MediaStream — tok video-audio dát. Tento objekt priraďujeme do vlastnosti src elementu video. Kód:

Vysielajúca strana

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

Aby sme mohli vysielať video stream cez sockety, je potrebné ho nejako kódovať, bufferovať a posielať po častiach. Surový video stream nie je možné poslať cez websockets. Tu nám prichádza na pomoc MediaRecorder API. Toto API umožňuje kódovanie a rozdelenie streamu na kusy. Kódovanie robím na zmenšenie video streamu, aby sa menej posielali bajty cez sieť. Rozdelením na kusy je možné každý kus odoslať na websocket. Kód:

Kódujeme video stream, rozdelíme ho na kusy

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

Teraz pridajme prenos cez websockets. Ako prekvapivé, na to potrebujeme len objekt WebSocket. Má iba dve metódy send a close. Názvy hovoria samy za seba. Doplnkový kód:

Prenášame video stream na server

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

Vysielajúca strana je pripravená! Teraz sa pokúsme prijímať video stream a zobrazíme ho na klientovi. Čo na to potrebujeme? Predovšetkým samozrejme socketové spojenie. Na objekt WebSocket prilepíme „poslucháča“ (listener), prihlásime sa na udalosť 'message'. Po prijatí kusu binárnych dát náš server ho rozposiela svojim prihláseným, teda klientom. Na strane klienta sa spustí callback-funkcia súvisiaca s „poslucháčom“ udalosti 'message', do argumentu funkcie sa posiela samotný objekt — kúsok video streamu, zakódovaný vp8.

Prijímame video stream

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

Dlho som sa snažil pochopiť, prečo nie je možné okamžite odoslať získané kúsky na prehrávanie do elementu video, ale nakoniec som zistil, že to naozaj nie je možné. Najprv je potrebné umiestniť kúsok do špeciálneho vyrovnávacieho pamäťového priestoru priradeného k elementu video, a až potom začne prehrávať videostream. Na toto bude potrebné MediaSource API a FileReader API.

MediaSource funguje ako akýsi prostredník medzi objektom prehrávania médií a zdrojom tohto médií. Objekt MediaSource obsahuje pripojenú vyrovnávaciu pamäť pre zdroj video/audio streamu. Jednou z vlastností je, že vyrovnávacia pamäť môže obsahovať iba dáta typu Uint8, takže na vytvorenie takejto vyrovnávacej pamäte bude potrebný FileReader. Pozrite si kód, a okamžite to bude jasnejšie:

Prehrávame videostream

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

Prototyp streamovacej služby je pripravený. Hlavnou nevýhodou je, že prehrávanie videa bude zaostávať za vysielacou stranou o 100 ms, čo sme nastavili sami pri rozdelení videostreamu pred jeho odoslaním na server. Navyše, keď som to testoval na svojom notebooku, postupne sa vytváralo oneskorenie medzi vysielacou a prijímajúcou stranou, čo bolo zjavné. Začal som hľadať spôsoby, ako sa zbaviť tohto nedostatku, a... natrafil som na RTCPeerConnection API, ktorý umožňuje prenášať videostream bez obvyklých trikov ako je rozdeľovanie streamu na kúsky. Domnievam sa, že akumulované oneskorenie je spôsobené tým, že v prehliadači sa pri prenose každého kúsku vykonáva prekodovanie do formátu webm. Už som sa ďalej neodvážil pátrať, začal som skúmať WebRTC a o výsledkoch svojich zistení pravdepodobne napíšem samostatný článok, ak to považujem za zaujímavé pre komunitu.

Zdroj: habr.com

Kúpte si spoľahlivý hosting pre webové stránky s ochranou proti DDoS, VPS VDS servery 🔥 Kúpte si spoľahlivý hosting pre webové stránky s ochranou proti DDoS, VPS VDS servery | ProHoster