(Peaaegu) kasutu veebikaamera voogedastus brauserist. Media Stream ja Websockets

Artiklis tahan jagada oma katsetusi video voogesitamiseks websocketsi abil ilma kolmandate osapoolte brauseri pistikprogrammide, näiteks Adobe Flash Playeri, kasutamiseta. Mida ma selle käigus saavutasin, loe edasi.

Adobe Flash – endine Macromedia Flash – on veebibrauseris töötavate rakenduste arendamise platvorm. Enne Media Stream API kasutuselevõttu oli see peaaegu ainus platvorm videot ja häält veebikaamerast voogesitamiseks ning erinevat tüüpi konverentside ja vestluste loomiseks brauseris. Meediaülekande protokoll RTMP (Real Time Messaging Protocol) oli pikka aega suletud, mis tähendas, et kui soovid enda voogesitusteenust luua, pead kasutama Adobe enda tarkvara – Adobe Media Serverit (AMS).

Mõne aja pärast, 2012. aastal, „alasid Adobe ja viskasid” publikule RTMP protokolli spetsifikatsiooni, mis sisaldas vigu ja ei olnud sisuliselt täielik. Sel ajal hakkasid arendajad meisterdama oma rakendusi selle protokolli järgi, mille tulemusena ilmus server Wowza. 2011. aastal esitas Adobe Wowza vastu kohtuhagi RTMP-ga seotud patentide ebaseadusliku kasutamise tõttu, kuid neli aastat hiljem lahendati konflikt rahumeelselt. RTMP protokolli, mis sisaldas vigu ja oli põhimõtteliselt puudulik. Sel ajal hakkasid arendajad looma oma renditavaid rakendusi selle protokolli jaoks, mistõttu tekkis Wowza server. 2011. aastal esitas Adobe Wowza vastu kohtuhagi RTMP-ga seotud patentide ebaseadusliku kasutamise tõttu, konflikt lahenes 4 aastat hiljem rahumeelselt.

Adobe Flash on olnud platvorm juba üle 20 aasta, mille jooksul on avastatud palju kriitilisi haavatavusi. Toetust lubati lõpetada 2020. aastaks, nii et streaming teenuste alternatiive ei jäägi palju.

Oma projekti jaoks otsustasin ma kohe täielikult loobuda Flashi kasutamisest brauseris. Peamine põhjus, mille olen eespool märkinud, on see, et Flashi ei toetata üldse mobiilsetes platvormides ning Adobe Flashi käitamine Windowsi (Wine emulaatoris) arendamiseks ei olnud mul sugugi soov. Seetõttu hakkasin kirjutama klienti JavaScriptis. See saab olema vaid prototüüp, kuna hiljem sain teada, et streamingut saab korraldada palju tõhusamalt p2p baasil, kuid mul on see peer – server – peer, kuid sellest räägime hiljem, sest see pole veel valmis.

Käivitamiseks vajame me tegelikult websockets-serverit. Ma tegin kõige lihtsama põhjal go-paketti melody:

Serveri koodi

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

         // Saate voogedastamine
	m.HandleMessageBinary(func(s *melody.Session, msg []byte) {
		m.BroadcastBinary(msg)
	})

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

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

Kliendipool (voogedastav külg) peab kõigepealt saama juurdepääsu kaamerale. See saavutatakse läbi MediaStream API.

Juurdepääsu (load) saamine kaamerale/mikrofonile toimub läbi Media Devices API. See API pakub meetodit MediaDevices.getUserMedia(), mis kuvab hüpikakna, küsides kasutajalt kaamera või mikrofoniga juurdepääsu lubamist. Tahan märkida, et kõik katsed olen teinud Google Chrome'is, aga arvan, et Firefoxis töötab kõik enam-vähem sama.

Seejärel tagastab getUserMedia() Promise'i, mis edastab MediaStream objekti — video-audio andmevoo. Selle objekti omistame video elemendi src omadusele. Kood:

Voogedastav külg

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

Videovoogude edastamiseks socketite kaudu tuleb see kuidagi kodeerida, puhverdada ja jagada osadeks. Toores videovoogu ei saa edastada websockets'i kaudu. Siin tuleb meile appi MediaRecorder API. See API võimaldab voogu kodeerida ja jagada tükkideks. Kodeerimine on vajalik videovoogude kokkusurumiseks, et edastada vähem baitide võrku. Jagades tükkideks, saab iga tüki saata websockets'i kaudu. Kood:

Kodeerime videovoogu, jagame selle osadeks

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

Nüüd lisame edastuse websockets'i kaudu. Ootamatult piisab selleks vaid objektist WebSocket. Sellel on vaid kaks meetodit: send ja close. Nimetused räägivad enda eest. Täiendatud kood:

Edastame videovoogude serverisse

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

Edastav pool on valmis! Nüüd proovime vastu võtta videovoogu ja näidata seda kliendile. Mida me selleks vajame? Esiteks loomulikult soketühendust. WebSocket objektile lisame „kuulaja” (listener), tellime ‘message’ sündmuse. Saades killu binaarsetestandmetest, meie server edastab selle tellijatele, st klientidele. Klientide puhul käivitub tagasihelistamisfunktsioon, mis on seotud ‘message’ sündmuse „kuulajaga”, funktsiooni argumendiks on tegelikult objekt ise — killu videovoogu, kodeeritud vp8.

Vastame videovoole

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

Pikka aega üritasin mõista, miks ei saa saadud kile kohe video elemendile esitamiseks saata, kuid selgus, et seda tõepoolest ei saa teha, kuna kõigepealt tuleb kill panna spetsiaalsesse puhveresse, mis on seotud video elemendiga, ja alles siis hakkab videovoog mängima. Selleks on vaja MediaSource API ja FileReader API.

MediaSource toimib teatud vahendajana meedia esitamise objekti ja meediaooja vahel. MediaSource objekt sisaldab ühendatavat puhvrit video/audiovoogude allikale. Üks iseloomulik omadus on see, et puhver võib sisaldada ainult Uint8 tüüpi andmeid, seega puhvri loomiseks on vajalik FileReader. Vaadake koodi, siis on see selgem:

Esitame video voogu

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

Striimimisteenuse prototüüp on valmis. Peamine miinus on see, et video esitamine jääb edastamisest maha 100 ms, kuna see on seatud selliselt videovoogude jagamise hetkel enne serverisse edastamist. Veelgi enam, kui ma oma sülearvutis seda kontrollisin, hakkas mul järk-järgult tekkima viivitus edastamise ja vastuvõtu vahel, mis oli hästi nähtav. Hakkasin otsima viise, kuidas seda puudust ületada ja… sattusin RTCPeerConnection API, mis võimaldab edastada videovoogu ilma selliste trikkideta nagu voolu lõhkumine tükkideks. Kogunev viivitus, nagu ma arvan, tuleneb sellest, et enne edastamist toimub iga tüki kodeerimine veebm-formaati brauseris. Ma ei uurinud enam edasi ja hakkasin uurima WebRTC-d, minu uurimise tulemuste kohta arvan, et kirjutan eraldi artikli, kui pean seda kogukonnale huvitavaks.

Allikas: habr.com

Osta usaldusväärne veebihosting DDoS kaitsega, VPS VDS serverid 🔥 Osta usaldusväärne veebihosting DDoS kaitsega, VPS VDS serverid | ProHoster