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

от автора

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


ссылка на оригинал статьи https://habr.com/ru/post/461769/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *