Streamen Sie eine Videodatei mit Node.js auf einen HTML5-Videoplayer, damit die Videosteuerung weiterhin funktioniert?

91

Tl; Dr - Die Frage:

Was ist der richtige Weg, um eine Videodatei mit Node.js auf einen HTML5-Videoplayer zu streamen, damit die Videosteuerelemente weiterhin funktionieren?

Ich denke , das hat damit zu tun, wie die Header behandelt werden. Wie auch immer, hier sind die Hintergrundinformationen. Der Code ist etwas langwierig, aber ziemlich einfach.

Das Streamen kleiner Videodateien zu HTML5-Videos mit Node ist einfach

Ich habe gelernt, wie man sehr einfach kleine Videodateien auf einen HTML5-Videoplayer streamen kann. Mit diesem Setup funktionieren die Steuerelemente ohne meinerseits und die Videostreams funktionieren einwandfrei. Eine Arbeitskopie des voll funktionsfähigen Codes mit Beispielvideo steht hier zum Download in Google Text & Tabellen zur Verfügung .

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>

Server:

// 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);

Diese Methode ist jedoch auf Dateien mit einer Größe von <1 GB beschränkt.

Streaming von Videodateien (beliebiger Größe) mit fs.createReadStream

Durch die Verwendung fs.createReadStream()kann der Server die Datei in einem Stream lesen, anstatt alles auf einmal in den Speicher einzulesen. Das klingt nach der richtigen Vorgehensweise, und die Syntax ist äußerst einfach:

Server-Snippet:

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);
});

Dies überträgt das Video ganz gut! Die Videosteuerung funktioniert jedoch nicht mehr.

WebDeveloper404
quelle
1
Ich habe diesen writeHead()Code kommentiert, aber für den Fall, dass es hilft. Sollte ich das entfernen, um das Code-Snippet besser lesbar zu machen?
WebDeveloper404
3
Woher kommt req.headers.range? Ich werde immer undefiniert, wenn ich versuche, die Ersetzungsmethode auszuführen. Vielen Dank.
Chad Watkins

Antworten:

115

Der Accept RangesHeader (das Bit in writeHead()) ist erforderlich, damit die HTML5-Videosteuerelemente funktionieren.

Ich denke, anstatt nur blind die vollständige Datei zu senden, sollten Sie zuerst den Accept RangesHeader in der ANFRAGE überprüfen , dann einlesen und genau dieses Bit senden. fs.createReadStreamUnterstützung startund endOption dafür.

Also habe ich ein Beispiel ausprobiert und es funktioniert. Der Code ist nicht schön, aber leicht zu verstehen. Zuerst verarbeiten wir den Bereichskopf, um die Start- / Endposition zu erhalten. Dann verwenden wir fs.stat, um die Größe der Datei zu ermitteln, ohne die gesamte Datei in den Speicher einzulesen. Verwenden Sie schließlich, fs.createReadStreamum das angeforderte Teil an den Client zu senden.

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
quelle
3
Können wir diese Strategie verwenden, um nur einen Teil des Films zu senden, dh zwischen der 5. und 7. Sekunde? Gibt es eine Möglichkeit herauszufinden, was dieses Intervall dem Byte-Intervall von ffmpeg wie Bibliotheken entspricht? Vielen Dank.
Pembeci
8
Macht nichts aus meiner Frage. Ich fand die magischen Worte, um herauszufinden, was ich erreichen konnte: Pseudo-Streaming .
Pembeci
Wie kann dies funktionieren, wenn movie.mp4 aus irgendeinem Grund in einem verschlüsselten Format vorliegt und wir es entschlüsseln müssen, bevor wir es zum Browser streamen?
Saraf
@saraf: Es hängt davon ab, welcher Algorithmus für die Verschlüsselung verwendet wird. Funktioniert es mit Stream oder nur als vollständige Dateiverschlüsselung? Ist es möglich, dass Sie das Video einfach an einem temporären Ort entschlüsseln und wie gewohnt bereitstellen? Generell ist es möglich, aber es kann schwierig werden. Hier gibt es keine allgemeine Lösung.
Tungd
Hallo, Tungd, danke für die Antwort! Der Anwendungsfall ist ein Himbeer-Pi-basiertes Gerät, das als Medienverteilungsplattform für Entwickler von Bildungsinhalten fungiert. Es steht uns frei, den Verschlüsselungsalgorithmus zu wählen, der Schlüssel befindet sich in der Firmware - der Speicher ist jedoch auf 1 GB RAM begrenzt und die Inhaltsgröße beträgt ca. 200 GB (auf Wechselmedien - USB angeschlossen). Sogar so etwas wie der Clear Key-Algorithmus mit EME wäre in Ordnung - mit Ausnahme des Problems, dass in Chrom kein ARE auf dem ARM eingebaut ist. Nur dass das Wechselmedium allein nicht ausreichen sollte, um die Wiedergabe / das Kopieren zu ermöglichen.
Saraf
21

Die akzeptierte Antwort auf diese Frage ist fantastisch und sollte die akzeptierte Antwort bleiben. Ich bin jedoch auf ein Problem mit dem Code gestoßen, bei dem der Lesestream nicht immer beendet / geschlossen wurde. Ein Teil der Lösung war zu senden autoClose: truezusammen mit start:start, end:endin der zweiten createReadStreamarg.

Der andere Teil der Lösung bestand darin, das Maximum zu begrenzen, das chunksizein der Antwort gesendet wird. Die andere Antwort lautet endwie folgt:

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

... was bewirkt, dass der Rest der Datei von der angeforderten Startposition durch ihr letztes Byte gesendet wird, egal wie viele Bytes das sein mögen. Der Client-Browser hat jedoch die Möglichkeit, nur einen Teil dieses Streams zu lesen, und wird dies tun, wenn noch nicht alle Bytes benötigt werden. Dies führt dazu, dass der Stream-Lesevorgang blockiert wird, bis der Browser entscheidet, dass es Zeit ist, mehr Daten abzurufen (z. B. eine Benutzeraktion wie Suchen / Scrubben oder einfach durch Abspielen des Streams).

Ich musste diesen Stream schließen, weil ich das <video>Element auf einer Seite anzeigte, auf der der Benutzer die Videodatei löschen konnte. Die Datei wurde jedoch erst aus dem Dateisystem entfernt, als der Client (oder Server) die Verbindung geschlossen hat, da nur so der Stream beendet / geschlossen wurde.

Meine Lösung bestand nur darin, eine maxChunkKonfigurationsvariable festzulegen, auf 1 MB festzulegen und niemals einen Lese-Stream von mehr als 1 MB gleichzeitig an die Antwort weiterzuleiten.

// 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;
}

Dadurch wird sichergestellt, dass der Lesestream nach jeder Anforderung beendet / geschlossen wird und vom Browser nicht am Leben gehalten wird.

Ich habe auch eine separate Frage und Antwort zu StackOverflow zu diesem Thema geschrieben.

danludwig
quelle
Dies funktioniert hervorragend für Chrome, scheint jedoch in Safari nicht zu funktionieren. In Safari scheint es nur zu funktionieren, wenn es die gesamte Reichweite anfordern kann. Machst du etwas anderes für Safari?
f1lt3r
2
Beim weiteren Graben: Safari sieht das "/ $ {total}" in der 2-Byte-Antwort und sagt dann ... "Hey, wie wäre es, wenn Sie mir einfach die ganze Datei senden?". Wenn dann gesagt wird: "Nein, Sie erhalten nur die ersten 1 MB!", Ist Safari verärgert. "Beim Versuch, die Ressource zu führen, ist ein Fehler aufgetreten."
f1lt3r