(Aproape) streaming inutil al camerei web din browser. Media Stream și Websockets

În acest articol, vreau să împărtășesc încercările mele de a realiza un streaming video prin websockets fără a utiliza plugin-uri externe de browser, precum Adobe Flash Player. Ce a ieșit din asta, citiți în continuare.

Adobe Flash – anterior Macromedia Flash, este o platformă pentru crearea aplicațiilor care funcționează în browser-ul web. Până la introducerea Media Stream API, aceasta a fost practic singura platformă pentru streaming video și audio de pe webcam, precum și pentru crearea diferitelor tipuri de conferințe și chaturi în browser. Protocolul pentru transmiterea informațiilor media RTMP (Real Time Messaging Protocol) a fost, de fapt, un protocol închis timp de multă vreme, ceea ce însemna: dacă vrei să îți ridici serviciul de streaming, trebuie să folosești software de la Adobe – Adobe Media Server (AMS).

După ceva vreme, în 2012, Adobe „s-a predat și a aruncat” în fața publicului specificația prototipul protocolului RTMP, care conținea erori și, în esență, era incomplet. La acel moment, dezvoltatorii au început să facă propriile implementări ale acestui protocol, așa că a apărut serverul Wowza. În 2011, Adobe a dat în judecată Wowza pentru utilizarea ilegală a brevetelor legate de RTMP, iar după 4 ani, conflictul s-a rezolvat pe cale amiabilă.

Platforma Adobe Flash are deja mai bine de 20 de ani, iar în acest timp au fost descoperite numeroase vulnerabilități critice, suportul promit urmând să fie oprit în 2020, așa că alternativele pentru serviciile de streaming nu mai sunt atât de multe.

Pentru proiectul meu, am decis imediat să renunț complet la utilizarea Flash în browser. Principalul motiv l-am menționat mai sus, de asemenea, Flash nu este deloc susținut pe platformele mobile, iar să instalez Adobe Flash pentru dezvoltare pe Windows (într-un emulator wine) nu prea mi-a plăcut. Așa că m-am apucat să scriu un client în JavaScript. Acesta va fi doar un prototip, deoarece ulterior am aflat că streamingul poate fi realizat mult mai eficient pe baza p2p, doar că la mine va fi peer – server – peers, dar despre asta altădată, pentru că nu este încă gata.

Pentru a începe lucrul, avem nevoie de un server websockets. Am realizat unul foarte simplu pe baza pachetului go melody:

Codul părții de server

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

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

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

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

Pe client (partea de transmitere), mai întâi trebuie să obțineți acces la cameră. Acest lucru se realizează prin MediaStream API.

Obținem acces (permisie) la cameră/microfon prin API-ul Media Devices. Acest API oferă metoda MediaDevices.getUserMedia(), care afișează o fereastră pop-up, întrebând utilizatorul pentru permisiunea de a accesa camera sau/și microfonul. Aș dori să menționez că toate experimentele le-am realizat în Google Chrome, dar, cred că în Firefox va funcționa cam la fel.

Apoi, getUserMedia() returnează un Promise, care transmite un obiect MediaStream – un flux de date video-audio. Acest obiect îl atribuim proprietății src a elementului video. Cod:

Partea de transmitere

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

Pentru a transmite un flux video prin socket-uri, trebuie să-l codificăm, să-l bufferizăm și să-l transmitem în bucăți. Un flux video brut nu poate fi trimis prin websockets. Aici ne ajută API-ul MediaRecorder. Acest API permite codificarea și împărțirea fluxului în bucăți. Codificarea o fac pentru a comprima fluxul video, astfel încât să transmit mai puțini biți pe rețea. Împărțind în bucăți, fiecare bucată poate fi trimisă prin websocket. Cod:

Codificăm fluxul video, îl împărțim în părți

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

Acum să adăugăm transmiterea prin websockets. Deosebit de surprinzător, pentru aceasta avem nevoie doar de un obiect WebSocket. Are doar două metode: send și close. Numele vorbesc de la sine. Codul completat:

Transmitem fluxul video pe 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>

Partea de transmitere este gata! Acum haideți să încercăm să primim fluxul video și să-l afișăm pe client. Ce ne trebuie pentru asta? În primul rând, desigur, o conexiune socket. Pe obiectul WebSocket atașăm un „ascultător” (listener), ne abonăm la evenimentul ‘message’. Primind o bucată de date binare, serverul nostru o bântuie abonaților săi, adică clienților. Pe client, funcția callback asociată cu „ascultătorul” evenimentului ‘message’ se va declanșa, în argumentul funcției fiind transmis efectiv obiectul – o bucată din fluxul video, codificat vp8.

Primim fluxul video

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

Am încercat mult timp să înțeleg de ce nu pot trimite imediat segmentele obținute pentru a fi redate de elementul video, dar se dovedește că nu se poate face așa, trebuie mai întâi să pui segmentul într-un buffer special legat de elementul video și abia apoi va începe să redea fluxul video. Pentru asta e nevoie de API MediaSource și API FileReader.

MediaSource acționează ca un intermediar între obiectul de redare media și sursa acestui flux media. Obiectul MediaSource conține un buffer conectabil pentru sursa fluxului video/audio. O caracteristică este că bufferul poate conține doar date de tip Uint8, astfel că pentru a crea un astfel de buffer este necesar un FileReader. Uitați-vă la cod și va fi mai clar:

Redarea fluxului video

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

Prototipul serviciului de streaming este gata. Principalul minus este că redarea video va rămâne în urma părții transmițătoare cu 100 ms, aceasta fiind o setare pe care am stabilit-o noi atunci când am segmentat fluxul video înainte de a-l trimite pe server. Mai mult, când am verificat pe laptop-ul meu, am observat că se acumula un lag între partea transmițătoare și cea receptoare, ceea ce era evident. Am început să caut modalități de a depăși această problemă și… am dat peste API RTCPeerConnection, care permite transmiterea fluxului video fără artificii de tipul segmentării fluxului în bucăți. Lagul acumulat, cred, se datorează faptului că în browser are loc o recodare a fiecărui segment în format webm înainte de transmitere. Nu am continuat să aprofundez și am început să studiez WebRTC, iar despre rezultatele cercetărilor mele cred că voi scrie un articol separat, dacă consider că este interesant pentru comunitate.

Sursa: habr.com

Cumpără un hosting fiabil pentru site-uri cu protecție DDoS, servere VPS VDS 🔥 Cumpără un hosting fiabil pentru site-uri cu protecție DDoS, servere VPS VDS | ProHoster