(Majdnem) haszontalan webkamera streaming a böngészőből. Media Stream és Websockets

A cikkben megosztom kísérleteimet, hogy videó streaminget végezzek websockets használatával, külső böngésző pluginok, mint az Adobe Flash Player, nélkül. Az eredményekről a továbbiakban olvashatnak.

Az Adobe Flash, korábban Macromedia Flash, egy platform, amely lehetővé teszi web böngészőben futó alkalmazások készítését. A Media Stream API bevezetése előtt ez volt szinte az egyetlen platform a videó- és hangstreaminghez webkamerából, illetve különféle konferenciák és csevegések létrehozásához a böngészőben. A média információk továbbítására szolgáló protokoll, az RTMP (Real Time Messaging Protocol) hosszú időn át lényegében zárt volt, ami azt jelentette: ha szeretnél streaming szolgáltatást indítani, akkor használd az Adobe szoftverét — az Adobe Media Server-t (AMS).

Egy idő után, 2012-ben az Adobe "megadta magát és közzétette" a specifikációt az RTMP protokollt, amely hibákat tartalmazott és lényegében nem volt teljes. Ekkorra a fejlesztők elkezdték saját megvalósításaikat ennek a protokollnak, így jött létre a Wowza szerver. 2011-ben az Adobe pert indított a Wowza ellen az RTMP-hez kapcsolódó szabadalmak illegális használata miatt, és négy év múlva a konfliktust békés úton rendezte.

Az Adobe Flash platform már több mint 20 éve létezik, ez idő alatt számos kritikus sebezhetőségre derült fény, a támogatást ígérték 2020-ra befejezni, így a streaming szolgáltatások alternatívái nem túl gazdagok.

A projekt során azonnal döntöttem arról, hogy teljesen megszabadulok a Flash használatától a böngészőben. Az alapvető okot fentebb megemlítettem, ráadásul a Flash mobil platformokon egyáltalán nem támogatott, és nem akartam az Adobe Flash-t Windows-on (Wine emulátor) futtatni a fejlesztéshez. Ezért elkezdtem JavaScript alapú klienst írni. Ez csupán egy prototípus lesz, mivel később megtudtam, hogy a streaminget sokkal hatékonyabban lehet p2p alapú megoldással készíteni, csak nálam peer — server — peers kapcsolat lesz, de erről majd egy másik alkalommal, mert ez még nem készült el.

A munka kezdetéhez szükségünk van egy websockets szerverre. Készítettem egy egyszerűt a go-melody csomag alapján:

A szerveroldali kód

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

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

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

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

A kliensen (a közvetítő oldalon) először is hozzáférést kell nyernie a kamerához. Ezt a következő módon lehet elérni: MediaStream API.

Hozzáférést (engedélyt) kérünk a kamerához/mikrofonhoz a következőn keresztül: Media Devices API. Ez az API egy módszert biztosít: MediaDevices.getUserMedia(), amely egy felugró ablakot mutat, amely megkérdezi a felhasználót, hogy engedélyezi-e a kamerához és/vagy mikrofonhoz való hozzáférést. Megjegyzendő, hogy minden kísérletemet a Google Chrome-ban végeztem, de úgy gondolom, hogy a Firefoxban is nagyjából hasonlóan működik.

Ezután a getUserMedia() egy ígéretet ad vissza, amely tartalmazza a MediaStream objektumot — a videó-audio adatfolyamot. Ezt az objektumot hozzárendeljük a video elem src tulajdonságához. Kód:

Közvetítő oldal

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

A videófolyam átkonvertálásához, tömörítéséhez és darabokban történő továbbításához szükséges valamilyen módszert alkalmazni. A nyers videófolyamot nem lehet websockets-en keresztül továbbítani. Ebben segít nekünk a MediaRecorder API. Ez az API lehetővé teszi a folyam kódolását és darabokra osztását. A kódolást a videófolyam tömörítése érdekében végzem, hogy kevesebb bájtot küldjünk át a hálózaton. Miután darabokra osztottuk, minden darabot elküldhetünk a websocket-en. Kód:

Kódoljuk a videófolyamot, darabokra osztva

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

Most adjuk hozzá a websockets-en való továbbítást. Mint meglepő, ehhez csupán egy WebSocket. Két metódusa van: send és close. A nevek magukért beszélnek. Kiegészített kód:

Küldjük a videófolyamot a szerverre

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

A közvetítő oldal készen áll! Most próbáljuk meg fogadni a videófolyamot, és megjeleníteni a kliensen. Mire lesz szükségünk hozzá? Először is természetesen a socket-kapcsolatra. A WebSocket objektumra felrakunk egy „figyelőt” (listener), amely feliratkozik az ‘üzenet’ eseményre. A kapott bináris adatrészletet a szerverünk továbbítja az előfizetőknek, tehát a klienseknek. A kliensek oldalán eközben a “listener” eseményhez kapcsolódó callback-függvény aktiválódik, amelynek argumént a saját objektumát — a vp8 kódolt videófolyam darabját — kapja meg.

Fogadjuk a videófolyamot

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

Hosszú ideig próbáltam megérteni, miért nem lehet a kapott darabokat azonnal lejátszásra küldeni a video elemnek, de kiderült, hogy ezt valóban nem lehet megtenni, először a darabot egy speciális puffert kell helyezni, amely a video elemhez van kötve, és csak ezután kezdheti el lejátszani a videófolyamot. Ehhez szükség lesz MediaSource API és FileReader API.

A MediaSource egy közvetítő szerepet tölt be a média lejátszó objektum és a médiafolyam forrása között. A MediaSource objektum tartalmaz egy csatlakoztatott puffert a videó/audiostream forráshoz. Az egyik sajátossága, hogy a puffer csak Uint8 típusú adatokat tartalmazhat, ezért egy ilyen puffer létrehozásához FileReader szükséges. Nézd meg a kódot, és világosabb lesz:

Videófolyam lejátszása

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

A streaming szolgáltatás prototípusa elkészült. Fő hátránya, hogy a videolejátszás 100 ms-el késlekedik a továbbító félhez képest, ezt mi állítottuk be a videófolyam szakaszokra bontásakor a szerverre történő továbbítás előtt. Ráadásul, amikor a laptopomon ellenőriztem, fokozatosan felhalmozódott a késlekedés a továbbító és az átvevő fél között, ez jól látható volt. Elkezdtem keresni a módját, hogyan lehetne megszüntetni ezt a hiányosságot, és... rátaláltam a RTCPeerConnection API, amely lehetővé teszi a videófolyam átvitelét anélkül, hogy bármiféle trükköt, például a folyam darabokra bontását kellene alkalmazni. A felhalmozódó késlekedést úgy gondolom, hogy az okozza, hogy a böngészőben az átvitel előtt minden darabot újrakódolnak webm formátumba. Már nem akartam tovább kutatni, és elkezdtem tanulmányozni a WebRTC-t, az eredményeimről valószínűleg külön cikket írok, ha érdekesnek találom a közösség számára.

Forrás: habr.com

Megbízható hosting vásárlása weboldalak számára, DDoS védelemmel, VPS VDS szerverek. 🔥 Megbízható webtárhely vásárlása DDoS védelemmel, VPS VDS szerverek | ProHoster