(Quase) streaming inútil de webcam pelo navegador. Media Stream e Websockets

No artigo, quero compartilhar minhas tentativas de fazer streaming de vídeo através de websockets sem usar plugins de navegador de terceiros como o Adobe Flash Player. O que consegui fazer, você pode ler a seguir.

Adobe Flash — anteriormente Macromedia Flash, é uma plataforma para criar aplicativos que funcionam em navegadores da web. Antes da implementação do Media Stream API, essa era praticamente a única plataforma para streaming de vídeo e áudio de webcam, além de criar diversos tipos de conferências e chats no navegador. O protocolo para transmissão de informações de mídia, RTMP (Real Time Messaging Protocol), foi essencialmente fechado por muito tempo, o que significava que, se você quisesse estabelecer seu serviço de streaming, precisava utilizar o software da própria Adobe — Adobe Media Server (AMS).

Após algum tempo, em 2012, a Adobe 'desistiu e lançou' ao público uma especificação o protocolo RTMP, que continha erros e, essencialmente, era incompleto. Nessa época, os desenvolvedores começaram a criar suas próprias implementações desse protocolo, surgindo assim o servidor Wowza. Em 2011, a Adobe processou a Wowza por uso não autorizado de patentes relacionadas ao RTMP, e após 4 anos, o conflito foi resolvido pacificamente.

A plataforma Adobe Flash existe há mais de 20 anos, e durante esse tempo foram descobertas inúmeras vulnerabilidades críticas, o suporte prometeram encerrar em 2020, então as alternativas para serviços de streaming não são tão numerosas assim.

Para meu projeto, decidi imediatamente cancelar completamente o uso do Flash no navegador. A principal razão, que mencionei acima, é que o Flash não é suportado em plataformas móveis, e além disso, não queria instalar o Adobe Flash para desenvolvimento no Windows (com emulador Wine). Portanto, decidi escrever um cliente em JavaScript. Isso será apenas um protótipo, pois descobri depois que o streaming pode ser feito de maneira muito mais eficiente com base em p2p, embora para mim isso será peer — server — peers, mas falaremos sobre isso em outra ocasião, pois ainda não está pronto.

Para começar a trabalhar, precisaremos de um servidor websockets. Eu fiz o mais simples baseado no pacote go melody:

Código da parte do servidor

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

Do lado do cliente (o lado de transmissão), é necessário primeiro obter acesso à câmera. Isso é feito através de MediaStream API.

Obtemos acesso (permissão) à câmera/microfone através de Media Devices API. Esta API fornece o método MediaDevices.getUserMedia(), que exibe uma janela pop-up solicitando permissão do usuário para acessar a câmera e/ou microfone. Vale ressaltar que todas as experiências foram realizadas no Google Chrome, mas acredito que no Firefox funcionará de maneira semelhante.

Em seguida, o getUserMedia() retorna uma Promise, que transmite um objeto MediaStream — um fluxo de dados de vídeo e áudio. Atribuímos este objeto à propriedade src do elemento video. Código:

O lado de transmissão

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

Para transmitir o fluxo de vídeo através de sockets, é necessário codificá-lo, armazená-lo em buffer e enviá-lo em partes. Um fluxo de vídeo bruto não pode ser enviado através de websockets. Aqui entra em cena MediaRecorder API. Esta API permite codificar e dividir o fluxo em pedaços. A codificação é feita para comprimir o fluxo de vídeo, para que menos bytes sejam transmitidos pela rede. Ao dividi-lo em partes, cada pedaço pode ser enviado pelo websocket. Código:

Codificamos o fluxo de vídeo, dividimos em partes

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

Agora vamos adicionar a transmissão via websockets. Para nossa surpresa, isso requer apenas um objeto WebSocket. Ele tem apenas dois métodos: send e close. Os nomes falam por si. Código atualizado:

Transmitimos o fluxo de vídeo para o servidor

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

O lado de transmissão está pronto! Agora vamos tentar receber o fluxo de vídeo e exibi-lo no cliente. O que precisamos para isso? Primeiro, claro, uma conexão de socket. No objeto WebSocket, adicionamos um "ouvinte" (listener), nos inscrevemos no evento 'message'. Ao receber um pedaço de dados binários, nosso servidor o retransmite para os assinantes, ou seja, clientes. No cliente, a função de callback ligada ao 'ouvinte' do evento 'message' é acionada, passando o objeto - o pedaço do fluxo de vídeo, codificado em vp8.

Recebemos o fluxo de vídeo

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

Eu passei muito tempo tentando entender por que não podemos enviar os pedaços obtidos imediatamente para reprodução no elemento video, mas parece que isso realmente não pode ser feito. Primeiro, é necessário colocar o pedaço em um buffer especial associado ao elemento video, e só então a reprodução do fluxo de vídeo começará. Para isso será necessário API MediaSource e API FileReader.

MediaSource é um intermediário entre o objeto de reprodução de mídia e a fonte deste fluxo de mídia. O objeto MediaSource contém um buffer conectável para a fonte do fluxo de vídeo/aúdio. Uma característica é que o buffer só pode conter dados do tipo Uint8, portanto, será necessário um FileReader para criar esse buffer. Veja o código e ficará mais claro:

Fluxo de vídeo em reprodução

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

O protótipo do serviço de streaming está pronto. A principal desvantagem é que a reprodução do vídeo ficará com um atraso de 100 ms em relação ao lado transmissor, pois isso foi definido por nós ao dividir o fluxo de vídeo antes de enviá-lo para o servidor. Além disso, quando eu verifiquei no meu laptop, o atraso entre o lado transmissor e o lado receptor foi aumentando gradualmente, o que era claramente visível. Comecei a buscar formas de superar essa desvantagem e… encontrei API RTCPeerConnection, que permite transmitir o fluxo de vídeo sem artifícios como a divisão do fluxo em pedaços. Acredito que o atraso acumulado se deve ao fato de que, no navegador, ocorre a recodificação de cada pedaço para o formato webm antes da transmissão. Não investi mais nisso e comecei a estudar o WebRTC; sobre os resultados de minhas investigações, talvez escreva um artigo separado, se achar que será interessante para a comunidade.

Fonte: habr.com

Compre hospedagem confiável para sites com proteção contra DDoS, servidores VPS VDS 🔥 Compre hospedagem confiável para sites com proteção contra DDoS, servidores VPS VDS | ProHoster