Как я сделал (почти) бесполезный стриминг вебкамеры на Javascript

  • Tutorial
В статье я хочу поделиться своими попытками сделать стримминг видео через 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, но об этом в другой раз, потому что это еще не готово.

Для начала работы нам необходим собственно webscokets-сервер. Я сделал простейший на основе 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 свойство элемента 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');

// если доступен MediaDevices API, пытаемся получить доступ к камере (можно еще и к микрофону)
// getUserMedia вернет обещание, на которое подписываемся и полученный видеопоток в колбеке направляем в video объект на странице

if (navigator.mediaDevices.getUserMedia) {
        navigator.mediaDevices.getUserMedia({video: true}).then(function (stream) {
          // видео поток привязываем к video тегу, чтобы клиент мог видеть себя и контролировать 
          video.srcObject = s;
        });
}
</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, и только тогда начнет воспроизводить видеопоток. Для этого понадобится MediaSource API и FileReader API.

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, о результатах моих изысканий я думаю, напишу отдельную статью, если посчитаю такую интересной сообществу.
Поддержать автора
Поделиться публикацией

Комментарии 11

    +2

    Спасибо! Очень будет интересно почитать и про WebRTC.

      0
      Спасибо за статью!
        0
        Прикольно, а зачем? ))
          0
          Чтобы порнохаб теперь без шуток мог сообщить, что у него есть видео о том как Вы смотрели наш сайт. Увы, но такое применение более вероятно, чем видео чат с консультантом в интенет магазине.
            0
            Чёрное зеркало, третий сезон, третья серия?
            Вообще, остаётся ещё проблема с тем, что сначала нужно хакнуть браузер, чтобы он выдал поток с камеры, не спрашивая пользователя. Хотя рано или поздно всплывут подобные уязвимости. Так что нужно пользователей приучать заклеивать камеру скотчем или приделывать к ней hardware switch.
              0
              Будем надеяться что в hardware switch в будущем не будет бэкдоров от производителя или уязвимостей.
                0
                Если припаять свой на провод, подающий питание на камеру – проблем быть не должно. Если же будут подозрения – можно убрать встроенную камеру вообще, и подключать внешнюю только при необходимости.
              0

              У пользователя всплывающее окошко появится, если он сам разрешит, то да, можно смотреть или слушать его.

            0
            Прикольно. Я для этого использовал сначала Flashfoner, потом Red5pro (по работе, не более).
            А как ты останавливаешь публикацию потока?
              0
              Пока никак, но можно по какому-то событию в сокет от транслирующей стороны останавливать цикл с воспроизведением видео.
              проектирую сейчас стримминг через WebRTC сервер
                +1
                Dnar
                Надеюсь, что беглый поиск по MDN наводит меня на правильные предположения. Очень похоже, что при необходимости завершить трансляцию нужно сначала дёрнуть mediaRecorder.stop(), затем дождаться события «stop» (на mediaRecorder), после чего закрыть соединение – socket.close(). Последнее, возможно, требует указания подходящего кода (первый параметр), но в таком минималистичном варианте это вряд ли критично.

                Сразу прошу меня простить, не хочу, чтобы это выглядело придиркой… Даже сразу скажу, что не знаю ни melody, ни go. Но меня очень смущает серверная часть, честно говоря, ибо я не вижу разделения на транслятора и потребителя. Складывается ощущение, что если несколько человек зайдут – они все будут вести трансляцию «в один канал», итого в broadcast будет невоспроизводимый мусор. Мало того, складывается ощущение, что транслирующему будет в ответ на каждый его пакет приходить пакет-ответ c теми же самыми данными (просто без обработки на стороне его клиента/браузера).

                Не знаю, работает ли melody так, но не правильнее было бы иметь два melody.New(), на один принимать по одному пути (через отдельный r.Get) пакеты, а рассылать броадкастом на второй melody для тех, кто подключился по второму пути?

            Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

            Самое читаемое