(Майже) непотрібний стрімінг вебкамери з браузера. Media Stream та Websockets

У статті я хочу поділитися своїми спробами зробити стріммінґ відео через websockets без використання сторонніх плагінів браузера типу Adobe Flash Player. Що з того вийшло читайте далі.

Adobe Flash — раніше Macromedia Flash, це платформа для створення програм, що працюють у веб-браузері. До впровадження Media Stream API це була практично єдина платформа для стріммінгу з веб-камери відео та голосу, а також для створення різноманітних конференцій та чатів у браузері. Протокол для передачі медіа-інформації RTMP (Real Time Messaging Protocol), був фактично закритим довгий час, що означало: якщо хочеш підняти свій стріммінг-сервіс, будь ласка, використовуй софт від самих Adobe — Adobe Media Server (AMS).

Через деякий час у 2012 році Adobe «здалися і виплюнули» на суд публіки. специфікацію протоколу RTMP, яка містила помилки та по суті була не повною. На той час розробники почали виконувати свої реалізації цього протоколу, так з'явився сервер Wowza. У 2011 році Adobe подала позов на Wowza за нелегальне використання патентів пов'язаних з RTMP, через 4 роки конфлікт вирішився миром.

Adobe Flash платформі вже більше 20 років, за цей час виявилося безліч критичних уразливостей, підтримку обіцяли припинити до 2020 року, тож альтернатив для стрімінгового сервісу залишається не так вже й багато.

Для свого проекту я одразу вирішив повністю відмовитися від використання Flash у браузері. Основну причину я вказав вище, також Flash зовсім не підтримується на мобільних платформах, та й розгортати Adobe Flash для розробки на windows (емуляторі wine) зовсім не хотілося. Тому я взявся писати клієнт JavaScript. Це буде лише прототип, так як надалі я дізнався, що стрімінг можна зробити набагато ефективніше на основі p2p, тільки у мене це буде peer - server - peers, але про це в інший раз, тому що це ще не готово.

Для початку роботи нам необхідний власне websockets-сервер. Я зробив найпростіший на основі go-пакету melody:

Код серверної частини

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

         // Бродкастим видео поток 
	m.HandleMessageBinary(func(s *melody.Session, msg []byte) {
		m.BroadcastBinary(msg)
	})

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

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

На клієнті (стороні, що транслює) спочатку необхідно отримати доступ до камери. Робиться це через MediaStream API.

Отримуємо доступ (дозвіл) до камери/мікрофону через Media Devices API. Це API надає метод MediaDevices.getUserMedia(), Що показує спл. віконце, що запитує користувача дозволу доступу до камери або мікрофону. Хотілося б відзначити, що всі експерименти я проводив у Google Chrome, але, думаю, Firefox все буде працювати приблизно також.

Далі getUserMedia() повертає Promise, яке передає MediaStream об'єкт — потік відео-аудіо даних. Цей об'єкт ми надаємо в src властивість елемента відео. Код:

Транслююча сторона

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

Щоб транслювати відеопотік через сокети, необхідно його десь кодувати, буферизувати, і передавати частинами. Сирий відеопотік не передається через websockets. Тут на допомогу нам приходить MediaRecorder API. Даний API дозволяє кодувати та розбивати потік на шматочки. Кодування я роблю для стиснення відеопотоку, щоб менше ганяти байтів через мережу. Розбивши на шматки, можна кожний шматок відправити в websocket. Код:

Кодуємо відеопотік, б'ємо його на частини

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

Тепер додамо передачу по websockets. Як не дивно, для цього потрібний лише об'єкт WebSocket. Має всього два методи send та close. Назви говорять самі за себе. Доповнений код:

Передаємо відеопотік на сервер

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

Транслююча сторона готова! Тепер спробуймо приймати відеопотік і показувати його на клієнті. Що нам для цього знадобиться? По-перше, звичайно ж сокет-з'єднання. На об'єкт WebSocket вішаємо слухач (listener), підписуємося на подію 'message'. Отримавши шматочок бінарних даних, наш сервер бродкастить його передплатникам, тобто клієнтам. На клієнті при цьому спрацьовує callback-функція пов'язана з «слухачем» події message, в аргумент функції передається власне сам об'єкт — шматочок відеопотоку, закодований vp8.

Приймаємо відеопотік

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

Я довгий час намагався зрозуміти, чому не можна отримані шматочки відразу ж відправити на відтворення елементу video, але виявилося так, звичайно, не можна робити, потрібно спочатку шматочок покласти в спеціальний буфер, прив'язаний до елемента video, і тільки тоді почне відтворювати відеопотік. Для цього знадобиться API MediaSource и API FileReader.

MediaSource виступає певним посередником між об'єктом відтворення media та джерелом потоку медіа. MediaSource об'єкт містить буфер для джерела відео/аудіо потоку. Одна особливість полягає в тому, що буфер може містити лише дані типу Uint8, тому для створення такого буфера буде потрібно FileReader. Подивіться код, і стане зрозуміліше:

Відтворюємо відеопотік

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

Прототип стримінг-сервісу готовий. Основний мінус у тому, що відтворення відставатиме від сторони, що передає, на 100 мс, це ми задали самі при розбитті відеопотоку перед передачею на сервер. Більш того, коли я перевіряв у себе на ноутбуці, у мене поступово накопичувався лаг між стороною, що передає і приймає, це було добре видно. Я почав шукати способи як подолати цей недолік, і… набрів на RTCPeerConnection API, що дозволяє передавати відеопотік без хитрощів типу розбиття потоку на шматочки. Лаг, що накопичується, я думаю, через те, що в браузері перед передачею відбувається перекодування кожного шматочка у формат webm. Я вже не став далі копати, а почав вивчати WebRTC, про результати моїх досліджень я думаю, напишу окрему статтю, якщо вважаю таку цікаву спільноту.

Джерело: habr.com

Додати коментар або відгук