(Амаль) бескарысны стрымінг вэбкамеры з браўзэра. 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 уласцівасць элемента 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 = 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. Як ні дзіўна, для гэтага патрэбен толькі аб'ект WebSockets. Мае ўсяго два метады 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, аб выніках маіх пошукаў я думаю, напішу асобны артыкул, калі палічу такую ​​цікавай супольнасці.

Крыніца: habr.com

Дадаць каментар