В тази статия искам да споделя своите опити за стрийминг на видео чрез 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("Стартиране на сървъра...")
http.ListenAndServe(":3000", r)
}
На клиента (страната, която излъчва) първо е нужно да получим достъп до камерата. Това става чрез .
Получаваме достъп (разрешение) до камерата/микрофона чрез . Това API предлага метод , който показва изскачащ прозорец, питащ потребителя за разрешение за достъп до камерата и/или микрофона. Бих искал да отбележа, че всички експерименти проведох в 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. Тук на помощ идва . Този 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. Както не е изненада, за това е нужен единствено обектът . Има само два метода 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 служи като посредник между обекта за възпроизвеждане на медия и източника на медийните потоци. Обектът 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 мс, което го зададохме сами при разчупването на видеопотока преди изпращането на сървъра. Освен това, когато проверявах на лаптопа си, забелязах, че постепенно се натрупва закъснение между предавателната и приемата страна, което беше очевидно. Започнах да търся начини да преодолея този недостатък и… попаднах на , което позволява предаване на видеопоток без маневри като разчупване на потока на фрагменти. Натрупаното закъснение, мисля, е заради това, че в браузъра преди предаването се извършва повторно кодиране на всеки фрагмент в формат webm. Не се заинтересувах повече, а започнах да изучавам WebRTC; резултатите от моите проучвания, мисля, ще напиша в отделна статия, ако я сметна за интересна за общността.
Източник: habr.com
