Fazendo streaming de um arquivo de vídeo para um player de vídeo html5 com Node.js para que os controles de vídeo continuem funcionando?

100

Tl; Dr - A pergunta:

Qual é a maneira certa de lidar com o streaming de um arquivo de vídeo para um player de vídeo html5 com Node.js para que os controles de vídeo continuem funcionando?

Eu acho que tem a ver com a maneira que os cabeçalhos são manipulados. De qualquer forma, aqui estão as informações básicas. O código é um pouco longo, no entanto, é bastante direto.

Transmitir pequenos arquivos de vídeo para vídeo HTML5 com Node é fácil

Aprendi como transmitir pequenos arquivos de vídeo para um reprodutor de vídeo HTML5 com muita facilidade. Com esta configuração, os controles funcionam sem nenhum trabalho da minha parte, e o vídeo é transmitido perfeitamente. Uma cópia de trabalho do código totalmente funcional com vídeo de amostra está aqui, para download no 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);

Mas este método é limitado a arquivos <1 GB de tamanho.

Streaming (qualquer tamanho) de arquivos de vídeo com fs.createReadStream

Ao utilizar fs.createReadStream(), o servidor pode ler o arquivo em um fluxo, em vez de ler tudo na memória de uma vez. Parece a maneira certa de fazer as coisas, e a sintaxe é extremamente simples:

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

Isso reproduz o vídeo perfeitamente! Mas os controles de vídeo não funcionam mais.

WebDeveloper404
fonte
1
Deixei esse writeHead()código comentado, mas aí caso ajude. Devo remover isso para tornar o trecho de código mais legível?
WebDeveloper404
3
de onde vem o req.headers.range? Eu continuo ficando indefinido quando tento fazer o método de substituição. Obrigado.
Chad Watkins

Respostas:

120

O Accept Rangescabeçalho (o bit in writeHead()) é necessário para que os controles de vídeo HTML5 funcionem.

Acho que em vez de enviar cegamente o arquivo completo, você deve primeiro verificar o Accept Rangescabeçalho no REQUEST, depois ler e enviar apenas aquele pedaço. fs.createReadStreamsuporte starte endopção para isso.

Tentei um exemplo e funcionou. O código não é bonito, mas é fácil de entender. Primeiro, processamos o cabeçalho do intervalo para obter a posição inicial / final. Em seguida, usamos fs.statpara obter o tamanho do arquivo sem ler todo o arquivo na memória. Por fim, utilize fs.createReadStreampara enviar a peça solicitada ao 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
fonte
4
Podemos usar essa estratégia para enviar apenas uma parte do filme, ou seja, entre o 5º e o 7º segundo? Existe uma maneira de descobrir o que esse intervalo corresponde a qual intervalo de bytes por ffmpeg como bibliotecas? Obrigado.
pembeci de
8
Esqueça minha pergunta. Encontrei as palavras mágicas para descobrir como alcançar o que pedi: pseudo-streaming .
pembeci de
Como isso pode funcionar se, por algum motivo, movie.mp4 está em um formato criptografado e precisamos descriptografá-lo antes de transmitir para o navegador?
saraf,
@saraf: Depende do algoritmo usado para criptografia. Ele funciona com stream ou funciona apenas como criptografia de arquivo inteiro? É possível que você apenas descriptografe o vídeo em um local temporário e exiba-o normalmente? De um modo geral, é possível, mas pode ser complicado. Não há solução geral aqui.
terça
Oi, Tungd, obrigado por responder! o caso de uso é um dispositivo baseado em raspberry pi que atuará como uma plataforma de distribuição de mídia para desenvolvedores de conteúdo educacional. Somos livres para escolher o algoritmo de criptografia, a chave estará no firmware - mas a memória é limitada a 1 GB de RAM e o tamanho do conteúdo é de cerca de 200 GB (que estará em mídia removível - USB conectado). Mesmo algo como o algoritmo Clear Key com O EME ficaria bem - exceto pelo problema de que o cromo não tem EME embutido no ARM. Só que a mídia removível por si só não deve ser suficiente para permitir a reprodução / cópia.
Saraf
25

A resposta aceita para esta pergunta é impressionante e deve permanecer a resposta aceita. No entanto, tive um problema com o código em que o fluxo de leitura nem sempre estava sendo encerrado / fechado. Parte da solução foi enviar autoClose: truejunto com start:start, end:endo segundo createReadStreamarg.

A outra parte da solução era limitar o máximo chunksizeenviado na resposta. A outra resposta definida endassim:

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

... que tem o efeito de enviar o resto do arquivo da posição inicial solicitada até seu último byte, não importa quantos bytes sejam. No entanto, o navegador do cliente tem a opção de ler apenas uma parte desse fluxo, e o fará, se ainda não precisar de todos os bytes. Isso fará com que o fluxo lido seja bloqueado até que o navegador decida que é hora de obter mais dados (por exemplo, uma ação do usuário como buscar / limpar ou apenas reproduzir o fluxo).

Eu precisava que este fluxo fosse fechado porque estava exibindo o <video>elemento em uma página que permitia ao usuário excluir o arquivo de vídeo. No entanto, o arquivo não estava sendo removido do sistema de arquivos até que o cliente (ou servidor) fechasse a conexão, porque essa é a única maneira que o fluxo estava sendo encerrado / fechado.

Minha solução foi apenas definir uma maxChunkvariável de configuração, defini-la para 1 MB e nunca canalizar uma leitura de um fluxo de mais de 1 MB por vez para a resposta.

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

Isso tem o efeito de garantir que o fluxo de leitura seja encerrado / fechado após cada solicitação e não seja mantido ativo pelo navegador.

Eu também escrevi uma pergunta StackOverflow separada e resposta cobrindo esse assunto.

danludwig
fonte
Isso funciona muito bem no Chrome, mas não parece funcionar no Safari. No safari, só parece funcionar se puder solicitar toda a gama. Você está fazendo algo diferente para o Safari?
f1lt3r
2
Após pesquisar mais: o Safari vê "/ $ {total}" na resposta de 2 bytes e, em seguida, diz ... "Ei, como você me envia o arquivo inteiro?". Então, quando é dito, "Não, você está obtendo apenas o primeiro 1Mb!", O Safari se irrita "Ocorreu um erro ao tentar conduzir o recurso".
f1lt3r
0

Em primeiro lugar, crie o app.jsarquivo no diretório que deseja 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);

Simplesmente execute node app.jse seu servidor estará rodando na porta 8080. Além de vídeo, ele pode transmitir todos os tipos de arquivos.

Ibrahim Shah
fonte