Streaming di un file video su un lettore video html5 con Node.js in modo che i controlli video continuino a funzionare?


96

Tl; Dr - La domanda:

Qual è il modo giusto per gestire lo streaming di un file video su un lettore video html5 con Node.js in modo che i controlli video continuino a funzionare?

Io penso che abbia a che fare con il modo in cui le intestazioni vengono gestiti. Comunque, ecco le informazioni di base. Il codice è un po ' lungo, tuttavia, è piuttosto semplice.

Lo streaming di piccoli file video in video HTML5 con Node è facile

Ho imparato a trasmettere in streaming piccoli file video a un lettore video HTML5 molto facilmente. Con questa configurazione, i controlli funzionano senza alcun intervento da parte mia e il video viene riprodotto in modo impeccabile. Una copia funzionante del codice completamente funzionante con video di esempio è qui, per il download su Google Docs .

Cliente:

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

Ma questo metodo è limitato ai file di dimensioni inferiori a 1 GB.

Streaming di file video (di qualsiasi dimensione) con estensione fs.createReadStream

Utilizzando fs.createReadStream(), il server può leggere il file in un flusso invece di leggerlo tutto in memoria in una volta. Sembra il modo giusto di fare le cose e la sintassi è estremamente semplice:

Snippet del server:

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

Questo riproduce il video perfettamente! Ma i controlli video non funzionano più.


1
Ho lasciato quel writeHead()codice commentato, ma nel caso fosse utile. Devo rimuoverlo per rendere più leggibile lo snippet di codice?
WebDeveloper404

3
da dove viene req.headers.range? Continuo a non essere definito quando provo a eseguire il metodo di sostituzione. Grazie.
Chad Watkins

Risposte:


118

L' Accept Rangesintestazione (il bit in writeHead()) è necessaria per il funzionamento dei controlli video HTML5.

Penso che invece di inviare semplicemente alla cieca il file completo, dovresti prima controllare l' Accept Rangesintestazione nella RICHIESTA, quindi leggere e inviare solo quel bit. fs.createReadStreamsupporto starte endopzione per questo.

Quindi ho provato un esempio e funziona. Il codice non è carino ma è facile da capire. Per prima cosa elaboriamo l'intestazione dell'intervallo per ottenere la posizione iniziale / finale. Quindi usiamo fs.statper ottenere la dimensione del file senza leggere l'intero file in memoria. Infine, utilizzare fs.createReadStreamper inviare la parte richiesta al client.

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

3
Possiamo usare questa strategia per inviare solo una parte del film, cioè tra il 5 ° e il 7 ° secondo? C'è un modo per trovare ciò che quell'intervallo corrisponde a quale intervallo di byte da ffmpeg come le librerie? Grazie.
pembeci

8
Non importa la mia domanda. Ho trovato le parole magiche per scoprire come ottenere ciò che chiedevo: pseudo-streaming .
pembeci

Come è possibile farlo funzionare se per qualche motivo movie.mp4 è in un formato crittografato e dobbiamo decrittografarlo prima dello streaming sul browser?
saraf

@saraf: dipende dall'algoritmo utilizzato per la crittografia. Funziona con lo streaming o funziona solo come crittografia dell'intero file? È possibile che tu decifri il video in una posizione temporanea e lo pubblichi come al solito? Parlando in generale, è possibile, ma può diventare complicato. Non c'è una soluzione generale qui.
tungd

Ciao, tungd, grazie per aver risposto! il caso d'uso è un dispositivo basato su Raspberry Pi che fungerà da piattaforma di distribuzione multimediale per gli sviluppatori di contenuti educativi. Siamo liberi di scegliere l'algoritmo di crittografia, la chiave sarà nel firmware, ma la memoria è limitata a 1 GB di RAM e la dimensione del contenuto è di circa 200 GB (che sarà su un supporto rimovibile - USB collegato). Anche qualcosa come l'algoritmo Clear Key con EME andrebbe bene, tranne per il problema che il cromo non ha EME integrato su ARM. Solo che il supporto rimovibile da solo non dovrebbe essere sufficiente per abilitare la riproduzione / copia.
saraf

24

La risposta accettata a questa domanda è fantastica e dovrebbe rimanere la risposta accettata. Tuttavia ho riscontrato un problema con il codice in cui il flusso di lettura non veniva sempre terminato / chiuso. Parte della soluzione era inviare autoClose: trueinsieme start:start, end:endal secondo createReadStreamargomento.

L'altra parte della soluzione era limitare il numero massimo di messaggi chunksizeinviati nella risposta. L'altra risposta è impostata in questo endmodo:

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

... che ha l'effetto di inviare il resto del file dalla posizione iniziale richiesta attraverso il suo ultimo byte, non importa quanti byte possano essere. Tuttavia, il browser client ha la possibilità di leggere solo una parte di quel flusso e lo farà, se non ha ancora bisogno di tutti i byte. Ciò causerà il blocco della lettura dello stream fino a quando il browser non deciderà che è il momento di ottenere più dati (ad esempio un'azione dell'utente come ricerca / scrub o semplicemente riproducendo lo stream).

Avevo bisogno che questo flusso venisse chiuso perché stavo visualizzando l' <video>elemento su una pagina che consentiva all'utente di eliminare il file video. Tuttavia, il file non veniva rimosso dal filesystem fino a quando il client (o il server) non chiudeva la connessione, perché questo era l'unico modo in cui il flusso veniva terminato / chiuso.

La mia soluzione era semplicemente impostare una maxChunkvariabile di configurazione, impostarla su 1 MB e non inviare mai un flusso di lettura di più di 1 MB alla volta alla risposta.

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

Ciò ha l'effetto di assicurarsi che il flusso di lettura venga terminato / chiuso dopo ogni richiesta e non mantenuto attivo dal browser.

Ho anche scritto una domanda e una risposta StackOverflow separate su questo problema.


Funziona alla grande per Chrome, ma non sembra funzionare in Safari. In safari sembra funzionare solo se può richiedere l'intera gamma. Stai facendo qualcosa di diverso per Safari?
f1lt3r

2
Dopo ulteriori ricerche: Safari vede "/ $ {total}" nella risposta a 2 byte, e poi dice ... "Ehi, come mi hai mandato l'intero file?". Poi quando viene detto "No, stai ricevendo solo il primo 1Mb!", Safari si arrabbia "Si è verificato un errore durante il tentativo di guidare la risorsa".
f1lt3r

0

Per prima cosa crea il app.jsfile nella directory che desideri pubblicare.

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

Esegui semplicemente node app.jse il tuo server funzionerà sulla porta 8080. Oltre al video può trasmettere tutti i tipi di file.

Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.