Przesyłasz strumieniowo plik wideo do odtwarzacza wideo HTML5 za pomocą Node.js, aby elementy sterujące wideo nadal działały?

100

Tl; Dr - Pytanie:

Jaki jest właściwy sposób obsługi przesyłania strumieniowego pliku wideo do odtwarzacza wideo HTML5 za pomocą Node.js, aby elementy sterujące wideo nadal działały?

Myślę , że ma to związek ze sposobem obsługi nagłówków. W każdym razie, oto podstawowe informacje. Kod jest trochę długi, jednak jest dość prosty.

Przesyłanie strumieniowe małych plików wideo do wideo HTML5 za pomocą Node jest łatwe

Nauczyłem się, jak bardzo łatwo przesyłać strumieniowo małe pliki wideo do odtwarzacza wideo HTML5. W tej konfiguracji elementy sterujące działają bez żadnej pracy z mojej strony, a strumień wideo jest bezbłędny. Kopia robocza w pełni działającego kodu z przykładowym filmem jest tutaj do pobrania w Dokumentach Google .

Klient:

<html>
  <title>Welcome</title>
    <body>
      <video controls>
        <source src="movie.mp4" type="video/mp4"/>
        <source src="movie.webm" type="video/webm"/>
        <source src="movie.ogg" type="video/ogg"/>
        <!-- fallback -->
        Your browser does not support the <code>video</code> element.
    </video>
  </body>
</html>

Serwer:

// Declare Vars & Read Files

var fs = require('fs'),
    http = require('http'),
    url = require('url'),
    path = require('path');
var movie_webm, movie_mp4, movie_ogg;
// ... [snip] ... (Read index page)
fs.readFile(path.resolve(__dirname,"movie.mp4"), function (err, data) {
    if (err) {
        throw err;
    }
    movie_mp4 = data;
});
// ... [snip] ... (Read two other formats for the video)

// Serve & Stream Video

http.createServer(function (req, res) {
    // ... [snip] ... (Serve client files)
    var total;
    if (reqResource == "/movie.mp4") {
        total = movie_mp4.length;
    }
    // ... [snip] ... handle two other formats for the video
    var range = req.headers.range;
    var positions = range.replace(/bytes=/, "").split("-");
    var start = parseInt(positions[0], 10);
    var end = positions[1] ? parseInt(positions[1], 10) : total - 1;
    var chunksize = (end - start) + 1;
    if (reqResource == "/movie.mp4") {
        res.writeHead(206, {
            "Content-Range": "bytes " + start + "-" + end + "/" + total,
                "Accept-Ranges": "bytes",
                "Content-Length": chunksize,
                "Content-Type": "video/mp4"
        });
        res.end(movie_mp4.slice(start, end + 1), "binary");
    }
    // ... [snip] ... handle two other formats for the video
}).listen(8888);

Ale ta metoda jest ograniczona do plików o rozmiarze <1 GB.

Przesyłanie strumieniowe (dowolnego rozmiaru) plików wideo w formacie fs.createReadStream

Wykorzystując fs.createReadStream(), serwer może odczytać plik w strumieniu, zamiast wczytywać go w całości do pamięci naraz. Brzmi to jak właściwy sposób robienia rzeczy, a składnia jest niezwykle prosta:

Fragment serwera:

movieStream = fs.createReadStream(pathToFile);
movieStream.on('open', function () {
    res.writeHead(206, {
        "Content-Range": "bytes " + start + "-" + end + "/" + total,
            "Accept-Ranges": "bytes",
            "Content-Length": chunksize,
            "Content-Type": "video/mp4"
    });
    // This just pipes the read stream to the response object (which goes 
    //to the client)
    movieStream.pipe(res);
});

movieStream.on('error', function (err) {
    res.end(err);
});

To dobrze przesyła wideo! Ale sterowanie wideo już nie działa.

WebDeveloper404
źródło
1
Zostawiłem ten writeHead()kod skomentowany, ale na wszelki wypadek to pomoże. Czy powinienem to usunąć, aby fragment kodu był bardziej czytelny?
WebDeveloper404
3
skąd pochodzi req.headers.range? Ciągle jestem niezdefiniowany, kiedy próbuję wykonać metodę zamiany. Dzięki.
Chad Watkins

Odpowiedzi:

120

Accept RangesHeader (bit w writeHead()) jest wymagana dla kontroli HTML5 video do pracy.

Myślę, że zamiast po prostu ślepo wysłać pełny plik, powinieneś najpierw sprawdzić Accept Rangesnagłówek w WNIOSKU, a następnie przeczytać i wysłać tylko ten bit. fs.createReadStreamwsparcie starti endopcja do tego.

Spróbowałem więc na przykładzie i działa. Kod nie jest ładny, ale jest łatwy do zrozumienia. Najpierw przetwarzamy nagłówek zakresu, aby uzyskać pozycję początkową / końcową. Następnie używamy, fs.stataby uzyskać rozmiar pliku bez wczytywania całego pliku do pamięci. Na koniec użyj, fs.createReadStreamaby wysłać żądaną część do klienta.

var fs = require("fs"),
    http = require("http"),
    url = require("url"),
    path = require("path");

http.createServer(function (req, res) {
  if (req.url != "/movie.mp4") {
    res.writeHead(200, { "Content-Type": "text/html" });
    res.end('<video src="http://localhost:8888/movie.mp4" controls></video>');
  } else {
    var file = path.resolve(__dirname,"movie.mp4");
    fs.stat(file, function(err, stats) {
      if (err) {
        if (err.code === 'ENOENT') {
          // 404 Error if file not found
          return res.sendStatus(404);
        }
      res.end(err);
      }
      var range = req.headers.range;
      if (!range) {
       // 416 Wrong range
       return res.sendStatus(416);
      }
      var positions = range.replace(/bytes=/, "").split("-");
      var start = parseInt(positions[0], 10);
      var total = stats.size;
      var end = positions[1] ? parseInt(positions[1], 10) : total - 1;
      var chunksize = (end - start) + 1;

      res.writeHead(206, {
        "Content-Range": "bytes " + start + "-" + end + "/" + total,
        "Accept-Ranges": "bytes",
        "Content-Length": chunksize,
        "Content-Type": "video/mp4"
      });

      var stream = fs.createReadStream(file, { start: start, end: end })
        .on("open", function() {
          stream.pipe(res);
        }).on("error", function(err) {
          res.end(err);
        });
    });
  }
}).listen(8888);
tungd
źródło
4
Czy możemy wykorzystać tę strategię do przesłania tylko części filmu, tj. Między 5 a 7 sekundą? Czy istnieje sposób, aby dowiedzieć się, jaki interwał odpowiada interwałowi bajtów w ffmpeg, takich jak biblioteki? Dzięki.
pembeci
8
Mniejsza o moje pytanie. Znalazłem magiczne słowa, aby dowiedzieć się, jak osiągnąć to, o co prosiłem: pseudo-streaming .
pembeci
Jak to działa, jeśli z jakiegoś powodu plik movie.mp4 jest w zaszyfrowanym formacie i musimy go odszyfrować przed przesłaniem strumieniowym do przeglądarki?
saraf
@saraf: To zależy od algorytmu używanego do szyfrowania. Czy działa ze strumieniem, czy działa tylko jako szyfrowanie całego pliku? Czy to możliwe, że po prostu odszyfrowujesz wideo do tymczasowej lokalizacji i udostępniasz je jak zwykle? Mówiąc ogólnie, jest to możliwe, ale może być trudne. Nie ma tutaj ogólnego rozwiązania.
tungd
Cześć, dziękuję za odpowiedź! przypadkiem użycia jest urządzenie oparte na raspberry pi, które będzie działać jako platforma dystrybucji mediów dla twórców treści edukacyjnych. Mamy swobodę wyboru algorytmu szyfrowania, klucz będzie w oprogramowaniu sprzętowym - ale pamięć jest ograniczona do 1 GB RAM, a rozmiar zawartości to około 200 GB (który będzie na nośniku wymiennym - podłączonym USB). Nawet coś w rodzaju algorytmu Clear Key z EME byłoby w porządku - poza problemem polegającym na tym, że chrom nie ma wbudowanego EME na ARM. Tyle, że sam nośnik wymienny nie powinien wystarczyć do odtwarzania / kopiowania.
saraf
25

Przyjęta odpowiedź na to pytanie jest niesamowita i powinna pozostać zaakceptowaną odpowiedzią. Jednak napotkałem problem z kodem, w którym strumień odczytu nie zawsze był kończony / zamykany. Część rozwiązania polegała na wysłaniu autoClose: truewraz z start:start, end:enddrugim createReadStreamargumentem.

Drugą częścią rozwiązania było ograniczenie maksymalnej liczby chunksizewysyłanej w odpowiedzi. Druga odpowiedź brzmi endtak:

var end = positions[1] ? parseInt(positions[1], 10) : total - 1;

... co skutkuje wysłaniem reszty pliku od żądanej pozycji początkowej do ostatniego bajtu, bez względu na to, ile może to być bajtów. Jednak przeglądarka klienta ma opcję odczytu tylko części tego strumienia i zrobi to, jeśli nie potrzebuje jeszcze wszystkich bajtów. Spowoduje to zablokowanie odczytu strumienia, dopóki przeglądarka nie zdecyduje, że nadszedł czas, aby uzyskać więcej danych (na przykład działanie użytkownika, takie jak wyszukiwanie / przewijanie lub po prostu odtwarzając strumień).

Potrzebowałem tego strumienia, aby został zamknięty, ponieważ wyświetlałem <video>element na stronie, który pozwolił użytkownikowi usunąć plik wideo. Jednak plik nie był usuwany z systemu plików, dopóki klient (lub serwer) nie zamknął połączenia, ponieważ tylko w ten sposób strumień był kończony / zamykany.

Moim rozwiązaniem było po prostu ustawienie maxChunkzmiennej konfiguracyjnej, ustawienie jej na 1 MB i nigdy nie przesyłanie do odpowiedzi strumienia więcej niż 1 MB na raz.

// same code as accepted answer
var end = positions[1] ? parseInt(positions[1], 10) : total - 1;
var chunksize = (end - start) + 1;

// poor hack to send smaller chunks to the browser
var maxChunk = 1024 * 1024; // 1MB at a time
if (chunksize > maxChunk) {
  end = start + maxChunk - 1;
  chunksize = (end - start) + 1;
}

Skutkuje to upewnieniem się, że strumień odczytu jest kończony / zamykany po każdym żądaniu i nie jest utrzymywany przy życiu przez przeglądarkę.

Napisałem również osobne pytanie dotyczące StackOverflow i odpowiedź na ten problem.

danludwig
źródło
Działa to świetnie w Chrome, ale nie działa w Safari. W safari wydaje się działać tylko wtedy, gdy może zażądać całego zakresu. Czy robisz coś innego dla Safari?
f1lt3r
2
Po dalszym kopaniu: Safari widzi „/ $ {total}” w 2-bajtowej odpowiedzi, a następnie mówi… „Hej, a co z tobą, po prostu wyślij mi cały plik?”. Następnie, gdy usłyszysz „Nie, otrzymujesz tylko pierwszy 1 MB!”, Safari denerwuje się „Wystąpił błąd podczas próby zarządzania zasobem”.
f1lt3r
0

Najpierw utwórz app.jsplik w katalogu, który chcesz opublikować.

var http = require('http');
var fs = require('fs');
var mime = require('mime');
http.createServer(function(req,res){
    if (req.url != '/app.js') {
    var url = __dirname + req.url;
        fs.stat(url,function(err,stat){
            if (err) {
            res.writeHead(404,{'Content-Type':'text/html'});
            res.end('Your requested URI('+req.url+') wasn\'t found on our server');
            } else {
            var type = mime.getType(url);
            var fileSize = stat.size;
            var range = req.headers.range;
                if (range) {
                    var parts = range.replace(/bytes=/, "").split("-");
                var start = parseInt(parts[0], 10);
                    var end = parts[1] ? parseInt(parts[1], 10) : fileSize-1;
                    var chunksize = (end-start)+1;
                    var file = fs.createReadStream(url, {start, end});
                    var head = {
                'Content-Range': `bytes ${start}-${end}/${fileSize}`,
                'Accept-Ranges': 'bytes',
                'Content-Length': chunksize,
                'Content-Type': type
                }
                    res.writeHead(206, head);
                    file.pipe(res);
                    } else {    
                    var head = {
                'Content-Length': fileSize,
                'Content-Type': type
                    }
                res.writeHead(200, head);
                fs.createReadStream(url).pipe(res);
                    }
            }
        });
    } else {
    res.writeHead(403,{'Content-Type':'text/html'});
    res.end('Sorry, access to that file is Forbidden');
    }
}).listen(8080);

Po prostu uruchom, node app.jsa Twój serwer będzie działał na porcie 8080. Oprócz wideo może przesyłać strumieniowo wszystkie rodzaje plików.

Ibrahim Shah
źródło