(Fast) nutzloses Streaming von Webcam-Video über den Browser. Media Stream und Websockets

In diesem Artikel möchte ich meine Versuche teilen, Video-Streaming über Websockets ohne die Nutzung von Browser-Plugins wie Adobe Flash Player zu realisieren. Was dabei herausgekommen ist, lesen Sie weiter.

Adobe Flash – früher Macromedia Flash – ist eine Plattform zur Erstellung von Anwendungen, die im Webbrowser laufen. Vor der Einführung der Media Stream API war es praktisch die einzige Plattform für das Streaming von Video und Sprache über Webcams sowie zur Erstellung verschiedener Arten von Konferenzen und Chats im Browser. Das Protokoll zur Übertragung von Mediainformationen, RTMP (Real Time Messaging Protocol), war lange Zeit praktisch ein Closed Source, was bedeutete: Wenn Sie Ihren eigenen Streaming-Service einrichten wollten, mussten Sie die Software von Adobe selbst verwenden – Adobe Media Server (AMS).

Nach einiger Zeit im Jahr 2012 „gibt Adobe auf und spuckt“ die Spezifikation Spezifikation des RTMP-Protokolls, die Fehler enthielt und im Grunde unvollständig war. Zu diesem Zeitpunkt begannen Entwickler, ihre eigenen Implementierungen dieses Protokolls zu erstellen, und so entstand der Wowza-Server. 2011 verklagte Adobe Wowza wegen unrechtmäßiger Nutzung von Patenten im Zusammenhang mit RTMP, und nach 4 Jahren wurde der Konflikt friedlich gelöst.

Die Adobe Flash-Plattform ist jetzt über 20 Jahre alt, und in dieser Zeit wurden zahlreiche kritische Sicherheitsanfälligkeiten entdeckt, die Unterstützung versprochen soll bis 2020 eingestellt werden, sodass es nicht allzu viele Alternativen für Streaming-Services gibt.

Für mein Projekt habe ich mich sofort entschieden, vollständig auf die Nutzung von Flash im Browser zu verzichten. Der Hauptgrund wurde oben genannt, außerdem wird Flash auf mobilen Plattformen überhaupt nicht unterstützt, und es war mir auch nicht recht, Adobe Flash für die Entwicklung unter Windows (im Wine-Emulator) einzurichten. Daher machte ich mich daran, einen Client in JavaScript zu schreiben. Das wird nur ein Prototyp sein, da ich später erfahren habe, dass Streaming auf Basis von P2P viel effizienter realisiert werden kann, wobei ich dies als Peer – Server – Peers umsetze, aber dazu später mehr, denn das ist noch nicht fertig.

Um mit der Arbeit zu beginnen, benötigen wir zunächst einen eigenen Websockets-Server. Ich habe einen sehr einfachen auf Basis des Go-Pakets Melody erstellt:

Der Code des Serverteils

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

         // Streamen wir den Video-Stream 
	m.HandleMessageBinary(func(s *melody.Session, msg []byte) {
		m.BroadcastBinary(msg)
	})

	log.Println("Server wird gestartet...")

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

Auf der Client-Seite (Streaming-Seite) muss zunächst Zugriff auf die Kamera gewährt werden. Dies geschieht über MediaStream API.

Erteilen Sie Zugriff (Genehmigung) auf die Kamera/Mikrofon über Media Devices API. Diese API bietet die Methode MediaDevices.getUserMedia(), die ein Popup-Fenster anzeigt, in dem der Benutzer um Erlaubnis für den Zugriff auf die Kamera und/oder das Mikrofon gebeten wird. Ich möchte anmerken, dass ich alle Experimente in Google Chrome durchgeführt habe, aber ich denke, dass es in Firefox etwa gleich funktionieren sollte.

Anschließend gibt getUserMedia() ein Promise zurück, das ein MediaStream-Objekt überträgt – einen Videodatenstrom. Dieses Objekt weisen wir dem src-Eigenschaftselement des Video-Elements zu. Der Code:

Streaming-Seite

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

Um den Video-Stream über Sockets zu übertragen, muss er irgendwie kodiert, gepuffert und in Teilen übermittelt werden. Ein unbearbeiteter Video-Stream kann nicht über Websockets übertragen werden. Hier kommt uns MediaRecorder API. Diese API ermöglicht es, den Stream zu kodieren und in Stücke zu zerlegen. Ich mache die Kodierung zur Kompression des Video-Streams, um weniger Bytes über das Netzwerk zu senden. Nach dem Zerlegen in Stücke kann jedes Stück über Websocket gesendet werden. Der Code:

Wir kodieren den Video-Stream und zerlegen ihn in Teile

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

Jetzt fügen wir die Übertragung über Websockets hinzu. Überraschenderweise benötigen wir dafür nur ein Objekt WebSocket. Es hat nur zwei Methoden: send und close. Die Namen sprechen für sich. Der erweiterte Code:

Wir senden den Video-Stream an den 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>

Die Streaming-Seite ist bereit! Jetzt probieren wir, den Video-Stream zu empfangen und ihn auf dem Client anzuzeigen. Was benötigen wir dafür? Zunächst natürlich eine Socket-Verbindung. An das WebSocket-Objekt hängen wir einen „Listener“ (Listener) und abonnieren das Ereignis 'message'. Nachdem unser Server ein Stück binärer Daten erhalten hat, sendet er es an seine Abonnenten, das heißt die Clients. Auf der Client-Seite wird die Callback-Funktion aktiviert, die mit dem 'Listener' des Ereignisses 'message' verknüpft ist, wobei das Argument der Funktion tatsächlich das Objekt selbst ist – das Stück Video-Stream, kodiert im vp8.

Wir empfangen den 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>

Ich habe lange versucht zu verstehen, warum man die erhaltenen Stücke nicht sofort zum Video-Element senden kann. Es stellte sich heraus, dass man das nicht einfach machen kann; zuerst muss das Stück in einen speziellen Puffer gelegt werden, der mit dem Video-Element verbunden ist, und erst dann beginnt der Videostream zu spielen. Dafür braucht man MediaSource API und FileReader API.

MediaSource fungiert als Vermittler zwischen dem Media-Wiedergabeobjekt und der Quelle dieses Medienstreams. Das MediaSource-Objekt enthält einen Puffer, der für die Quelle des Video-/Audio-Streams verbunden ist. Eine Besonderheit ist, dass der Puffer nur Daten vom Typ Uint8 enthalten kann. Daher wird ein FileReader benötigt, um einen solchen Puffer zu erstellen. Schauen Sie sich den Code an, und es wird klarer:

Wir spielen den Videostream ab

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

Der Prototyp des Streaming-Dienstes ist fertig. Der Hauptnachteil besteht darin, dass die Videowiedergabe um 100 ms hinter der sendenden Seite zurückbleibt, was wir selbst bei der Unterteilung des Videostreams vor der Übertragung an den Server eingestellt haben. Außerdem, als ich es auf meinem Laptop überprüfte, bemerkte ich, dass sich ein Lag zwischen der sendenden und der empfangenden Seite allmählich aufbaute, was gut sichtbar war. Ich begann nach Wegen zu suchen, um diesen Nachteil zu überwinden, und... stieß auf RTCPeerConnection API, das es ermöglicht, den Videostream ohne Tricks wie das Aufteilen des Streams in Stücke zu übertragen. Der sich aufbauende Lag, denke ich, liegt daran, dass im Browser vor der Übertragung jedes Stück in das webm-Format umcodiert wird. Ich habe nicht weiter nachgeforscht, sondern begonnen, WebRTC zu studieren. Über die Ergebnisse meiner Untersuchungen denke ich, dass ich einen separaten Artikel schreiben werde, wenn ich es der Gemeinschaft interessant genug finde.

Quelle: habr.com

60GB SSD 8Gb DDR4