(Casi) inútil transmisión de la cámara web desde el navegador. Media Stream y Websockets

En este artículo, quiero compartir mis intentos de transmitir video a través de websockets sin utilizar complementos de navegador de terceros como Adobe Flash Player. Lo que logré se detalla a continuación.

Adobe Flash, anteriormente Macromedia Flash, es una plataforma para crear aplicaciones que operan en el navegador web. Antes de la implementación de Media Stream API, era prácticamente la única plataforma para transmitir video y audio desde una cámara web, así como para crear diversos tipos de conferencias y chats en el navegador. El protocolo para la transmisión de información multimedia, RTMP (Real Time Messaging Protocol), estuvo cerrado durante mucho tiempo, lo que significaba que, si querías lanzar tu servicio de streaming, debías utilizar el software de Adobe, como Adobe Media Server (AMS).

Después de un tiempo, en 2012, Adobe "se rindió y escupió" ante el público especificación un protocolo RTMP que contenía errores y, de hecho, estaba incompleto. Para ese momento, los desarrolladores comenzaron a crear sus propias implementaciones de este protocolo, lo que llevó a la creación del servidor Wowza. En 2011, Adobe demandó a Wowza por el uso ilegal de patentes relacionadas con RTMP, y después de 4 años, el conflicto se resolvió en paz.

La plataforma Adobe Flash lleva más de 20 años, y durante este tiempo se han descubierto numerosas vulnerabilidades críticas, y el soporte prometieron se detendrá en 2020, por lo que no hay muchas alternativas para el servicio de streaming.

Para mi proyecto, decidí desde el principio abandonar completamente el uso de Flash en el navegador. La razón principal la mencioné arriba, además, Flash no es compatible en plataformas móviles, y no quería lidiar con la instalación de Adobe Flash para desarrollar en Windows (emulador wine). Así que comencé a escribir un cliente en JavaScript. Esto será solo un prototipo, ya que más adelante descubrí que el streaming se puede hacer de manera mucho más eficiente basado en p2p, solo que en mi caso será peer - servidor - peers, pero eso es un tema para otra ocasión, ya que aún no está listo.

Para comenzar a trabajar, necesitamos un servidor de websockets. Hice uno muy simple basado en el paquete Go melody:

Código de la parte del 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)
	})

	// Transmitimos el flujo de video 
	m.HandleMessageBinary(func(s *melody.Session, msg []byte) {
		m.BroadcastBinary(msg)
	})

	log.Println("Iniciando el servidor...")

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

En el cliente (lado emisor), primero es necesario acceder a la cámara. Esto se hace a través de API MediaStream.

Obtenemos acceso (permiso) a la cámara/micrófono a través de API de Dispositivos Multimedia. Esta API proporciona el método MediaDevices.getUserMedia(), que muestra una ventana emergente preguntando al usuario por permiso para acceder a la cámara y/o micrófono. Cabe destacar que realicé todos los experimentos en Google Chrome, pero creo que en Firefox debería funcionar de manera similar.

Luego, getUserMedia() devuelve una Promesa, que transmite un objeto MediaStream: un flujo de datos de video y audio. Este objeto lo asignamos a la propiedad src del elemento video. Código:

Lado emisor

<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 el flujo de video a través de sockets, es necesario codificarlo, bufferizarlo y enviarlo en partes. No se puede transmitir un flujo de video crudo a través de websockets. Aquí nos ayuda API MediaRecorder. Esta API permite codificar y dividir el flujo en fragmentos. Realizo la codificación para comprimir el flujo de video, de modo que se transmitan menos bytes por la red. Una vez dividido en partes, cada fragmento se puede enviar a través de websocket. Código:

Codificamos el flujo de video, lo dividimos en 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>

Ahora agreguemos la transmisión a través de websockets. Sorprendentemente, solo necesitamos un objeto WebSocket. Tiene solo dos métodos: send y close. Los nombres hablan por sí mismos. Código complementado:

Transmitimos el flujo de video al 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>

¡El lado emisor está listo! Ahora intentemos recibir el flujo de video y mostrarlo en el cliente. ¿Qué necesitamos para esto? Primero, por supuesto, una conexión de socket. En el objeto WebSocket, colocamos un "escuchador" (listener), suscribiéndonos al evento 'message'. Al recibir un fragmento de datos binarios, nuestro servidor lo transmite a suscriptores, es decir, a los clientes. En el cliente, se activa la función callback relacionada con el "escuchador" del evento 'message', pasando como argumento el objeto mismo: un fragmento del flujo de video, codificado en vp8.

Recibimos el flujo de 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>

Durante mucho tiempo intenté entender por qué no se pueden enviar los fragmentos obtenidos directamente para reproducir en el elemento video, pero resulta que no se puede hacer así, primero hay que colocar el fragmento en un búfer especial vinculado al elemento video, y solo entonces comenzará a reproducir la secuencia de video. Para esto se necesitará MediaSource API y FileReader API.

MediaSource actúa como un intermediario entre el objeto de reproducción de media y la fuente de esta secuencia de media. El objeto MediaSource contiene un búfer conectable para la fuente de la secuencia de video/audio. Una característica es que el búfer solo puede contener datos del tipo Uint8, por lo que para crear dicho búfer se necesitará FileReader. Mira el código y se volverá más claro:

Reproduciendo la secuencia de 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>

El prototipo del servicio de streaming está listo. La principal desventaja es que la reproducción de video estará atrasada con respecto a la parte transmissora en 100 ms, esto lo decidimos nosotros mismos al dividir la secuencia de video antes de enviarla al servidor. Además, cuando lo comprobé en mi portátil, noté que se acumulaba un retraso entre la parte transmissora y la receptora, lo cual era bastante evidente. Comencé a buscar formas de superar este inconveniente y... me encontré con RTCPeerConnection API, que permite transmitir la secuencia de video sin artimañas como dividir la secuencia en fragmentos. Creo que el retraso acumulado se debe a que en el navegador, antes de enviar, se recodifica cada fragmento al formato webm. No seguí indagando más, y empecé a estudiar WebRTC, sobre los resultados de mis investigaciones creo que escribiré un artículo aparte si lo considero interesante para la comunidad.

Fuente: habr.com

Compra un hosting fiable para sitios web con protección contra DDoS, servidores VPS VDS 🔥 Compra un hosting fiable para sitios web con protección contra DDoS, servidores VPS VDS | ProHoster