¿Transmitir un archivo de video a un reproductor de video html5 con Node.js para que los controles de video continúen funcionando?

96

Tl; Dr - La pregunta:

¿Cuál es la forma correcta de manejar la transmisión de un archivo de video a un reproductor de video html5 con Node.js para que los controles de video continúen funcionando?

Yo creo que tiene que ver con la forma en que los encabezados se manejan. De todos modos, aquí está la información de fondo. El código es un poco extenso, sin embargo, es bastante sencillo.

Transmitir pequeños archivos de video a video HTML5 con Node es fácil

Aprendí a transmitir archivos de video pequeños a un reproductor de video HTML5 muy fácilmente. Con esta configuración, los controles funcionan sin ningún trabajo de mi parte, y el video se transmite sin problemas. Una copia de trabajo del código completamente funcional con un video de muestra está aquí, para descargar en 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>

Servidor:

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

Pero este método está limitado a archivos de menos de 1 GB de tamaño.

Transmisión de archivos de video (de cualquier tamaño) con fs.createReadStream

Al utilizarlo fs.createReadStream(), el servidor puede leer el archivo en una secuencia en lugar de leerlo todo en la memoria a la vez. Esta parece la forma correcta de hacer las cosas, y la sintaxis es extremadamente simple:

Fragmento de servidor:

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

¡Esto transmite el video perfectamente! Pero los controles de video ya no funcionan.

WebDeveloper404
fuente
1
Dejé ese writeHead()código comentado, pero ahí por si sirve de ayuda. ¿Debo eliminar eso para que el fragmento de código sea más legible?
WebDeveloper404
3
¿De dónde viene req.headers.range? Sigo sin definirme cuando trato de hacer el método de reemplazo. Gracias.
Chad Watkins

Respuestas:

118

El Accept Rangesencabezado (el bit in writeHead()) es necesario para que funcionen los controles de video HTML5.

Creo que en lugar de enviar ciegamente el archivo completo, primero debe verificar el Accept Rangesencabezado en la SOLICITUD, luego leer y enviar solo ese bit. fs.createReadStreamsoporte starty endopción para eso.

Así que probé un ejemplo y funciona. El código no es bonito pero es fácil de entender. Primero procesamos el encabezado del rango para obtener la posición inicial / final. Luego usamos fs.statpara obtener el tamaño del archivo sin leer el archivo completo en la memoria. Finalmente, utilice fs.createReadStreampara enviar la pieza solicitada al cliente.

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
fuente
3
¿Podemos usar esta estrategia para enviar solo una parte de la película, es decir, entre el quinto y el séptimo segundo? ¿Hay alguna manera de encontrar qué intervalo corresponde a qué intervalo de bytes por ffmpeg como bibliotecas? Gracias.
pembeci
8
No importa mi pregunta. Encontré las palabras mágicas para saber cómo lograr lo que pedí: pseudo-transmisión .
pembeci
¿Cómo se puede hacer que esto funcione si por alguna razón movie.mp4 está en un formato cifrado y necesitamos descifrarlo antes de transmitirlo al navegador?
Saraf
@saraf: Depende del algoritmo utilizado para el cifrado. ¿Funciona con stream o solo funciona como cifrado de archivos completos? ¿Es posible que simplemente descifre el video en una ubicación temporal y lo sirva como de costumbre? Hablando en general, es posible, pero puede ser complicado. Aquí no hay una solución general.
tungd
Hola, tungd, gracias por responder. el caso de uso es un dispositivo basado en raspberry pi que actuará como una plataforma de distribución de medios para los desarrolladores de contenido educativo. Somos libres de elegir el algoritmo de cifrado, la clave estará en el firmware, pero la memoria está limitada a 1 GB de RAM y el tamaño del contenido es de alrededor de 200 GB (que estará en un medio extraíble, USB conectado). Incluso algo como el algoritmo Clear Key con EME estaría bien, excepto por el problema de que el cromo no tiene EME integrado en el ARM. Solo que el medio extraíble por sí solo no debería ser suficiente para permitir la reproducción / copia.
Saraf
24

La respuesta aceptada a esta pregunta es asombrosa y debería seguir siendo la respuesta aceptada. Sin embargo, encontré un problema con el código en el que la transmisión de lectura no siempre se terminaba / cerraba. Parte de la solución fue enviar autoClose: truejunto con start:start, end:endel segundo createReadStreamargumento.

La otra parte de la solución fue limitar el máximo chunksizeenviado en la respuesta. La otra respuesta se establece endasí:

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

... que tiene el efecto de enviar el resto del archivo desde la posición inicial solicitada hasta su último byte, sin importar cuántos bytes pueda ser. Sin embargo, el navegador del cliente tiene la opción de leer solo una parte de ese flujo, y lo hará, si aún no necesita todos los bytes. Esto hará que la lectura de la transmisión se bloquee hasta que el navegador decida que es hora de obtener más datos (por ejemplo, una acción del usuario como buscar / eliminar, o simplemente reproducir la transmisión).

Necesitaba cerrar esta transmisión porque estaba mostrando el <video>elemento en una página que permitía al usuario eliminar el archivo de video. Sin embargo, el archivo no se estaba eliminando del sistema de archivos hasta que el cliente (o servidor) cerró la conexión, porque esa es la única forma en que la transmisión terminaba / cerraba.

Mi solución fue simplemente establecer una maxChunkvariable de configuración, establecerla en 1 MB y nunca canalizar una lectura de más de 1 MB a la vez a la respuesta.

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

Esto tiene el efecto de asegurarse de que el flujo de lectura finalice / cierre después de cada solicitud y que el navegador no lo mantenga activo.

También escribí una pregunta y respuesta de StackOverflow por separado que cubren este problema.

danludwig
fuente
Esto funciona muy bien para Chrome, pero no parece funcionar en Safari. En safari, solo parece funcionar si puede solicitar toda la gama. ¿Estás haciendo algo diferente para Safari?
f1lt3r
2
Al indagar más: Safari ve el "/ $ {total}" en la respuesta de 2 bytes, y luego dice ... "Oye, ¿qué tal si me envías el archivo completo?". Luego, cuando se le dice, "¡No, solo estás obteniendo el primer 1Mb!", Safari se enoja "Se produjo un error al intentar dirigir el recurso".
f1lt3r
0

En primer lugar, cree el app.jsarchivo en el directorio que desea publicar.

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

Simplemente ejecute node app.jsy su servidor se ejecutará en el puerto 8080. Además de video, puede transmitir todo tipo de archivos.

Ibrahim Shah
fuente