(Téměř) bezcenné streamování webkamery z prohlížeče. Media Stream a Websockets

V tomto článku se chci podělit o své pokusy o streamování videa přes websockety bez použití třetích stran jako je Adobe Flash Player. Co se mi podařilo, si přečtěte dále.

Adobe Flash – dříve Macromedia Flash, je platforma pro vytváření aplikací, které běží ve webovém prohlížeči. Před zavedením Media Stream API to byla téměř jediná platforma pro streamování videa a zvuku z webové kamery, stejně jako pro pořádání různých konferencí a chatů v prohlížeči. Protokol pro přenos mediálních informací RTMP (Real Time Messaging Protocol) byl dlouho fakticky uzavřený, což znamenalo: pokud chcete spustit svou streamingovou službu, budete muset používat software od Adobe – Adobe Media Server (AMS).

Po nějaké době, v roce 2012, se Adobe "vzdalo a předložilo" veřejnosti specifikaci protokolu RTMP, která obsahovala chyby a byla vlastně neúplná. V té době začali vývojáři vytvářet vlastní implementace tohoto protokolu, což vedlo k vzniku serveru Wowza. V roce 2011 žalovala Adobe společnost Wowza za nelegální využívání patentů souvisejících s RTMP, do čtyř let byl konflikt vyřešen mírovou cestou.

Platforma Adobe Flash existuje již více než 20 let, během této doby se objevilo mnoho kritických zranitelností, podpora slíbili má být ukončena v roce 2020, takže alternativ pro streamovací služby už není mnoho.

Pro svůj projekt jsem se rozhodl zcela vzdát používání Flashe v prohlížeči. Hlavní důvod jsem uvedl výše, také Flash není vůbec podporován na mobilních platformách, a rozbalovat Adobe Flash pro vývoj na Windows (v emulátoru wine) se mi opravdu nechtělo. Proto jsem se pustil do psaní klienta na JavaScriptu. To bude jen prototyp, protože jsem později zjistil, že streamování může být mnohem efektivnější na základě p2p, pouze u mě to bude peer — server — peers, ale o tom až jindy, protože to ještě není hotové.

Pro začátek nám vlastně potřebujeme websocketový server. Udělal jsem nejjednodušší na základě go balíku melody:

Kód serverové části

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

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

	log.Println("Spouští se server...")

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

Na klientovi (vysílající straně) je nejprve nutné získat přístup k kameře. To se provádí přes MediaStream API.

Získání přístupu (povolení) k kameře/mikrofonu pomocí Media Devices API. Toto API poskytuje metodu MediaDevices.getUserMedia(), která zobrazuje vyskakovací okno, ptající se uživatele na povolení přístupu k kameře nebo/i mikrofonu. Rád bych poznamenal, že všechny experimenty jsem prováděl v Google Chrome, ale myslím, že ve Firefoxu to bude fungovat podobně.

Následně getUserMedia() vrací Promise, do kterého předává objekt MediaStream — tok video-audio dat. Tento objekt přiřazujeme do src vlastnosti prvku video. Kód:

Vysílající 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>

Abychom mohli vysílat video stream přes sockety, je nutné jej nějak kódovat, bufferovat a posílat po částech. Syrový video stream se nedá poslat přes websockets. Zde přichází na pomoc MediaRecorder API. Tento API umožňuje kódovat a rozdělovat tok na kousky. Kódování provádím pro kompresi video streamu, abych posílal méně bajtů po síti. Rozdělením na kousky můžeme každý kus poslat do websocketu. Kód:

Kódujeme video stream, dělíme jej na části

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

Nyní přidáme přenos přes websockets. Jak překvapivé, k tomu potřebujeme pouze objekt WebSocket. Má pouze dvě metody send a close. Názvy mluví samy za sebe. Doplněný kód:

Přenášíme 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>

Vysílající strana je připravena! Nyní zkusme přijímat video stream a zobrazit jej na klientovi. Co k tomu budeme potřebovat? Za prvé, samozřejmě, socketové připojení. Na objekt WebSocket připojujeme "posluchače" (listener), přihlašujeme se na událost 'message'. Když obdržíme kousek binárních dat, náš server je broadcastuje svým odběratelům, tedy klientům. Na klientovi se pak spustí callback-funkce spojená s "posluchačem" události 'message', argumentem funkce je vlastně samotný objekt — kousek video streamu, zakódovaný vp8.

Přijímáme 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>

Dlouho jsem se snažil pochopit, proč není možné ihned odeslat získané útržky na přehrávání do prvku video, ale ukázalo se, že to tak opravdu nelze udělat. Nejprve je třeba útržek uložit do speciálního bufferu připojeného k prvku video, a teprve poté začne přehrávat video stream. K tomu bude potřeba MediaSource API a FileReader API.

MediaSource funguje jako prostředník mezi objektem přehrávání médií a zdrojem daného streamu. Objekt MediaSource obsahuje připojitelný buffer pro zdroj video/audio streamu. Jednou z vlastností je, že buffer může obsahovat pouze data typu Uint8, a proto k vytvoření takového bufferu bude potřeba FileReader. Podívejte se na kód a bude to jasnější:

Přehráváme 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'),
        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 streamovací služby je hotov. Hlavní nevýhodou je, že video přehrávání bude zaostávat za vysílací stranou o 100 ms, což jsme nastavili sami při dělení video streamu před odesláním na server. Navíc, když jsem to zkoušel na svém notebooku, postupně se mi hromadil lag mezi vysílací a přijímající stranou, což bylo dobře viditelné. Začal jsem hledat způsoby, jak tento nedostatek překonat, a… narazil jsem na RTCPeerConnection API, které umožňuje přenášet video stream bez jakýchkoliv triků, jako je rozbití streamu na kousky. Hromadící se lag si myslím, že vzniká proto, že v prohlížeči dochází před odesláním k převedení každého kousku do formátu webm. Už jsem to dále nezkoumal a začal studovat WebRTC; o výsledcích mých zkoumání si myslím, že napíšu samostatný článek, pokud to považuji za zajímavé pro komunitu.

Zdroj: habr.com

Koupit spolehlivý hosting pro webové stránky s ochranou proti DDoS, VPS VDS servery 🔥 Koupit spolehlivý hosting pro webové stránky s ochranou proti DDoS, VPS VDS servery | ProHoster