V članku se želim podeliti svoje poskuse streamanja videa preko websockets brez uporabe zunanjih dodatkov brskalnika, kot je Adobe Flash Player. Kaj mi je uspelo, preberite naprej.
Adobe Flash — prej Macromedia Flash, je platforma za ustvarjanje aplikacij, ki delujejo v spletnih brskalnikih. Pred uvedbo Media Stream API je bila praktično edina platforma za streamanje videa iz spletne kamere in zvoka, pa tudi za ustvarjanje različnih konferenc in klepetov v brskalniku. Protokol za prenos medijskih informacij RTMP (Real Time Messaging Protocol) je bil dolgo časa dejansko zaprt, kar je pomenilo: če želiš postaviti svojo streaming storitev, boš moral uporabiti programsko opremo podjetja Adobe — Adobe Media Server (AMS).
Po nekaj časa, leta 2012 je Adobe 'popustil in izstrelil' protokol RTMP na voljo javnosti. Ta protokol je imel napake in v bistvu ni bil popoln. Do takrat so razvijalci začeli ustvarjati svoje implementacije tega protokola, tako je nastal strežnik Wowza. Leta 2011 je Adobe tožil Wowzo za nelegalno uporabo patentov, povezanih z RTMP, in po štirih letih se je konflikt končal spravno.
Adobe Flash platforma je stara že več kot 20 let, v tem času so se pojavile številne kritične ranljivosti, podpora pa je bila načrtovana za ukinitev do leta 2020, tako da ni veliko alternativ za streaming storitev.
Za svoj projekt sem se takoj odločil, da se popolnoma odpovem uporabi Flasha v brskalniku. Glavni razlog sem navedel zgoraj, prav tako Flash na mobilnih platformah sploh ni podprt, prav tako pa se mi ni ljubilo nastavljati Adobe Flasha za razvoj na Windows (v emulatorju Wine). Zato sem se lotil pisanja odjemalca v JavaScriptu. To bo le prototip, saj sem kasneje ugotovil, da se lahko streamanje naredi precej učinkoviteje na osnovi p2p, le da bo pri meni to peer — server — peers, ampak o tem kdaj drugič, ker še ni pripravljeno.
Za začetek dela potrebujemo lastno websockets-strežnik. Naredil sem najpreprostejšega na osnovi go-paketa melody:
Koda strežniškega dela
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)
})
// Broadcasting video stream
m.HandleMessageBinary(func(s *melody.Session, msg []byte) {
m.BroadcastBinary(msg)
})
log.Println("Starting server...")
http.ListenAndServe(":3000", r)
}
Na odjemalcu (strani koja prenosi) najpre je potrebno dobiti pristup kameri. To se postiže kroz .
Dobijamo pristup (dozvolu) kameri/mikrofonu preko . Ovaj API pruža metodu , koja prikazuje iskačući prozor, tražeći od korisnika dozvolu za pristup kameri i/ili mikrofonu. Želim napomenuti da sam sve eksperimentisao u Google Chrome-u, ali mislim da će u Firefox-u sve raditi otprilike isto.
Nakon toga getUserMedia() vraća Promise, koja prosleđuje MediaStream objekat — tok video-audio podataka. Ovaj objekat dodeljujemo src svojstvu video elementa. Kod:
Strana koja prenosi
<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>
Da bismo prenosili video tok preko soketa, potrebno je da ga nekako kodiramo, keširamo i šaljemo u delovima. Sirovi video tok se ne može preneti preko websockets. Ovdje nam pomaže . Ovaj API omogućava kodiranje i deljenje toka na manje delove. Kodiranje radim radi smanjenja videa, kako bismo poslali manje bajtova putem mreže. Kada podelimo na delove, možemo svaki deo poslati u websocket. Kod:
Kodiramo video tok, delimo ga na delove
<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>
Sada dodajmo prenos preko websockets. Čudno je, ali za to je potrebno samo objekat . Ima samo dve metode send i close. Imena sama govore za sebe. Dopunjen kod:
Prenosimo video tok na server
<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>
Strana koja prenosi je spremna! Sada pokušajmo da primimo video tok i prikažemo ga na klijentu. Šta je potrebno za to? Prvo, naravno, soket-veza. Na WebSocket objekat postavljamo 'slušača' (listener), prijavljujemo se za događaj 'message'. Kada dobijemo komad binarnih podataka, naš server ga šalje svojim pretplatnicima, tj. klijentima. Na klijentu se aktivira callback funkcija vezana za 'slušača' događaja 'message', u argument funkcije se prosleđuje sam objekat — komad video toka, kodiran vp8.
Primamo video tok
<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>
Dugo sam pokušavao shvatiti zašto se stečeni delovi ne mogu odmah poslati na reprodukciju video elementu, ali se ispostavilo da to nije moguće; prvo je potrebno staviti deo u poseban bafer povezan sa video elementom, i tek tada će započeti reprodukcija video streama. Za ovo će biti potreban in .
MediaSource predstavlja posrednika između objekta za reprodukciju medija i izvora ovog medijskog streama. MediaSource objekat sadrži priključni bafer za izvor video/audio streama. Jedna posebnost je da bafer može sadržati samo podatke tipa Uint8, pa će za kreiranje takvog bafera biti potreban FileReader. Pogledajte kod i biće jasnije:
Reprodukujemo video stream
<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>
Prototip streaming usluge je spreman. Glavna mana je u tome što će reprodukcija videa kasniti u odnosu na izvor na 100 ms, što smo sami odredili prilikom deljenja video streama pre slanja na server. Štaviše, kada sam proveravao na svom laptopu, postepeno se akumulirao zaostatak između pošiljatelja i primaoca, što je bilo dobro vidljivo. Počeo sam tražiti načine kako da prevaziđem ovaj nedostatak i... naišao sam na , koji omogućava prenos video streama bez trikova kao što je deljenje streama na delove. Akumulirajući zaostatak, mislim, se dešava jer se u pregledaču pre slanja vrši re-enkodiranje svakog dela u webm format. Nisam nastavio dalje da istražujem, već sam počeo proučavati WebRTC, a rezultate svojih istraživanja ću možda napisati u posebnom članku, ako to smatram zanimljivim za zajednicu.
Izvor: habr.com
