W artykule chcę podzielić się swoimi próbami streamingu wideo przez websockets bez używania zewnętrznych wtyczek przeglądarki, takich jak Adobe Flash Player. Co z tego wyszło, przeczytasz dalej.
Adobe Flash – wcześniej Macromedia Flash, to platforma do tworzenia aplikacji działających w przeglądarce internetowej. Przed wprowadzeniem Media Stream API była praktycznie jedyną platformą do streamingu wideo i audio z kamery internetowej, a także do tworzenia różnych konferencji i czatów w przeglądarkach. Protokół do przesyłania informacji multimedialnych RTMP (Real Time Messaging Protocol) przez długi czas był praktycznie zamknięty, co oznaczało: jeśli chcesz uruchomić swój serwis streamingowy, musisz używać oprogramowania od Adobe - Adobe Media Server (AMS).
Po pewnym czasie w 2012 roku Adobe „poddało się i wypluło” na sąd publiczny protokół RTMP, który zawierał błędy i w zasadzie był niekompletny. W tym czasie deweloperzy zaczęli tworzyć swoje implementacje tego protokołu, tak powstał serwer Wowza. W 2011 roku Adobe złożyło pozew przeciwko Wowza za nielegalne korzystanie z patentów związanych z RTMP, a po 4 latach konflikt został rozwiązany polubownie.
Adobe Flash istnieje już ponad 20 lat, przez ten czas pojawiło się wiele krytycznych luk, a wsparcie zostało zaprzestane w 2020 roku, więc alternatyw dla usług streamingowych nie pozostało zbyt wiele.
Dla mojego projektu od razu zdecydowałem się całkowicie zrezygnować z używania Flash w przeglądarkach. Główny powód podałem powyżej, Flash nie jest także w ogóle wspierany na platformach mobilnych, a uruchamianie Adobe Flash do programowania na Windows (w emulatorze Wine) również nie było moim zamiarem. Dlatego wziąłem się za pisanie klienta w JavaScript. To będzie tylko prototyp, ponieważ później dowiedziałem się, że streaming można zrobić znacznie efektywniej w oparciu o p2p, tylko u mnie to będzie peer — server — peers, ale o tym innym razem, ponieważ to jeszcze nie jest gotowe.
Na początek potrzebujemy właściwego serwera websockets. Stworzyłem najprostszy na podstawie pakietu go melody:
Kod części serwerowej
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)
})
// Broadcastujemy strumień wideo
m.HandleMessageBinary(func(s *melody.Session, msg []byte) {
m.BroadcastBinary(msg)
})
log.Println("Uruchamiam serwer...")
http.ListenAndServe(":3000", r)
}
Na kliencie (stronie transmitującej) najpierw trzeba uzyskać dostęp do kamery. To się robi przez .
Uzyskujemy dostęp (uprawnienia) do kamery/mikrofonu przez . To API udostępnia metodę , która wyświetla wyskakujące okno pytające użytkownika o pozwolenie na dostęp do kamery lub/i mikrofonu. Chciałbym zauważyć, że wszystkie eksperymenty przeprowadzałem w Google Chrome, ale myślę, że w Firefox wszystko będzie działało podobnie.
Następnie getUserMedia() zwraca Promise, w które przekazuje obiekt MediaStream — strumień danych wideo-audio. Ten obiekt przypisujemy do właściwości src elementu video. Kode:
Strona transmitująca
<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>
Aby transmitować strumień wideo przez gniazda (sockets), musimy go jakoś zakodować, buforować i przesyłać w częściach. Surowego strumienia wideo nie można przesłać przez websockets. Tutaj z pomocą przychodzi nam . To API pozwala kodować i dzielić strumień na kawałki. Kodowanie wykonuję, aby skompresować strumień wideo, aby mniej danych przesyłać przez sieć. Podzielony na kawałki, każdy kawałek można wysłać w websocket. Kode:
Kodujemy strumień wideo, dzielimy go na części
<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>
Teraz dodamy przesyłanie przez websockets. Jak nie dziwnie, do tego potrzebny jest tylko obiekt . Ma tylko dwie metody: send i close. Nazwy mówią same za siebie. Uzupełniony kod:
Przesyłamy strumień wideo na serwer
<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>
Strona transmitująca gotowa! Teraz spróbujmy odbierać strumień wideo i pokazać go na kliencie. Co będziemy do tego potrzebować? Po pierwsze oczywiście połączenie z gniazdem. Na obiekt WebSocket dodajemy 'nasłuchiwacza' (listener), subskrybujemy zdarzenie 'message'. Otrzymując kawałek danych binarnych, nasz serwer broadcastuje je do subskrybentów, czyli klientów. Na kliencie uruchamia się callback-funkcja związana z 'nasłuchiwaczem' zdarzenia 'message', w argumencie funkcji przekazywany jest sam obiekt — kawałek strumienia wideo, zakodowanego vp8.
Odbieramy strumień wideo
<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>
Przez długi czas próbowałem zrozumieć, dlaczego nie można natychmiast wysłać uzyskanych fragmentów do odtwarzania elementu video, ale okazało się, że tak naprawdę nie można tego zrobić, najpierw trzeba umieścić fragment w specjalnym buforze związanym z elementem video, a dopiero potem zacznie odtwarzać strumień wideo. Będzie do tego potrzebne i .
MediaSource działa jako pośrednik między obiektem odtwarzania mediów a źródłem tego strumienia mediów. Obiekt MediaSource zawiera podłączany bufor dla źródła strumienia wideo/audio. Jedną z cech jest to, że bufor może zawierać tylko dane typu Uint8, dlatego aby stworzyć taki bufor, potrzebny będzie FileReader. Zobacz kod, a stanie się to bardziej zrozumiałe:
Odtwarzany strumień wideo
<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>
Prototyp usługi streamingowej jest gotowy. Główną wadą jest to, że odtwarzanie wideo będzie opóźnione o 100 ms względem strony nadającej, co sami ustaliśmy przy dzieleniu strumienia wideo przed przesłaniem na serwer. Co więcej, kiedy sprawdzałem to na swoim laptopie, stopniowo gromadziło się opóźnienie między stroną nadawczą a odbierającą, co było dobrze widoczne. Zaczłem szukać sposobów, jak przezwyciężyć ten problem i... natknąłem się na , które pozwala na przesyłanie strumienia wideo bez takich sztuczek, jak dzielenie strumienia na kawałki. Gromadzące się opóźnienie, myślę, wynika z tego, że w przeglądarce przed przesłaniem odbywa się ponowne kodowanie każdego fragmentu do formatu webm. Nie poszedłem już dalej w głąb tematu, a zacząłem badać WebRTC; o wynikach moich badań myślę, że napiszę osobny artykuł, jeśli uznam to za interesujące dla społeczności.
Źródło: habr.com
