У статті я хочу поділитися своїми спробами зробити стріммінґ відео через websockets без використання сторонніх плагінів браузера типу Adobe Flash Player. Що з того вийшло читайте далі.
Adobe Flash — раніше Macromedia Flash, це платформа для створення програм, що працюють у веб-браузері. До впровадження Media Stream API це була практично єдина платформа для стріммінгу з веб-камери відео та голосу, а також для створення різноманітних конференцій та чатів у браузері. Протокол для передачі медіа-інформації RTMP (Real Time Messaging Protocol), був фактично закритим довгий час, що означало: якщо хочеш підняти свій стріммінг-сервіс, будь ласка, використовуй софт від самих Adobe — Adobe Media Server (AMS).
Через деякий час у 2012 році Adobe «здалися і виплюнули» на суд публіки.
Adobe Flash платформі вже більше 20 років, за цей час виявилося безліч критичних уразливостей, підтримку
Для свого проекту я одразу вирішив повністю відмовитися від використання 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)
}
На клієнті (стороні, що транслює) спочатку необхідно отримати доступ до камери. Робиться це через
Отримуємо доступ (дозвіл) до камери/мікрофону через
Далі 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. Тут на допомогу нам приходить
Кодуємо відеопотік, б'ємо його на частини
<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. Як не дивно, для цього потрібний лише об'єкт
Передаємо відеопотік на сервер
<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 виступає певним посередником між об'єктом відтворення 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 мс, це ми задали самі при розбитті відеопотоку перед передачею на сервер. Більш того, коли я перевіряв у себе на ноутбуці, у мене поступово накопичувався лаг між стороною, що передає і приймає, це було добре видно. Я почав шукати способи як подолати цей недолік, і… набрів на
Джерело: habr.com