Programação Por Equipe DevBras

Capa do artigo: Vídeo que não dá play: Content-Type errado e servidor sem Range

Vídeo que não dá play: Content-Type errado e servidor sem Range

Confira com curl o tipo que o servidor anuncia e se ele responde 206 a pedidos de faixa de bytes, corrija no Nginx, no Apache ou no PHP e prepare o MP4 para começar rápido.

A escola online sobe a aula-01.mp4. No computador do professor, o arquivo abre perfeito. Na página, o player aparece, o botão de play gira e nada acontece no iPhone; no Chrome toca, mas arrastar a barra para o minuto 20 faz tudo recomeçar do zero. A resposta curta: o servidor precisa anunciar o tipo certo, video/mp4, e aceitar pedidos de faixa de bytes, respondendo 206. Sem o tipo, alguns navegadores recusam; sem faixa de bytes, ninguém consegue pular para o meio e o Safari pode nem começar.

O exemplo deste guia é essa aula, servida primeiro por um script PHP e depois direto pelo Nginx, com o diagnóstico feito todo com curl.

Por que o arquivo certo não toca

Quando a tag video pede o arquivo, o navegador lê o cabeçalho Content-Type da resposta para saber o que está chegando. Se vem application/octet-stream, que significa "bytes quaisquer", alguns navegadores farejam o conteúdo e tocam mesmo assim, outros não. Não conte com o faro. Os tipos corretos dos formatos comuns são video/mp4 para MP4 e video/webm para WebM. A extensão do arquivo não basta, porque quem decide o cabeçalho é o servidor; o assunto geral está em MIME type não é a extensão.

O segundo mecanismo é a faixa de bytes. Um vídeo de 46 MB não é baixado inteiro antes de tocar. O navegador pede pedaços com o cabeçalho Range: bytes=... e o servidor responde com status 206 Partial Content e o cabeçalho Content-Range. É isso que permite pular para o minuto 20. Servidores web servindo arquivo estático fazem isso sozinhos. Scripts que leem o arquivo e despejam na resposta, como um readfile() em PHP, normalmente não fazem, e respondem 200 com o arquivo inteiro a cada pedido. O Safari, em especial, é exigente com essa parte.

O terceiro é o próprio arquivo. Um MP4 tem um índice, o bloco moov, que diz onde está cada quadro. Muitos programas gravam esse índice no fim do arquivo. Aí o navegador precisa buscar o fim antes de tocar o começo, o que custa tempo, e sem faixa de bytes custa o download inteiro.

Como diagnosticar com curl

  1. Pegue o endereço exato do vídeo na aba Rede das ferramentas do navegador.
  2. Peça só os cabeçalhos e leia o Content-Type.
  3. Peça o primeiro quilobyte com Range e confira se a resposta é 206 com Content-Range.
  4. Se a resposta for 200 com o arquivo inteiro, o servidor ou o script não aceita faixa.
  5. Confira o codec com ffprobe e o lugar do índice com uma reexportação.
curl -I https://cursos.exemplo.com.br/aulas/aula-01.mp4

HTTP/2 200
content-type: application/octet-stream
content-length: 48234567
curl -s -o /dev/null -D - -H "Range: bytes=0-1023" https://cursos.exemplo.com.br/aulas/aula-01.mp4

HTTP/2 200
content-type: application/octet-stream
content-length: 48234567

Dois problemas na mesma resposta: tipo genérico e faixa ignorada, já que o pedido de 1.024 bytes voltou com o arquivo inteiro e status 200. A saída esperada depois da correção é esta:

HTTP/2 206
content-type: video/mp4
content-range: bytes 0-1023/48234567
content-length: 1024

Como corrigir em cada servidor

Nginx servindo o arquivo

http {
    include       mime.types;
    default_type  application/octet-stream;
}

O arquivo mime.types que acompanha o Nginx já mapeia mp4 e webm; confira com grep -E "mp4|webm" /etc/nginx/mime.types. Quando o vídeo sai como application/octet-stream, o motivo costuma ser um include mime.types esquecido ou um bloco types dentro de um server ou location. Esse bloco não soma à lista herdada: ele a substitui, e tudo o que não está nele cai no tipo padrão. Arquivo estático no Nginx já responde a faixa de bytes sem configuração extra.

Apache

AddType video/mp4 .mp4 .m4v
AddType video/webm .webm

Isso vai na configuração do site ou num .htaccess, se o AllowOverride permitir. O Apache também atende faixa de bytes para arquivo estático por conta própria.

Vídeo protegido servido por PHP

Quando só aluno logado pode ver a aula, é comum o PHP conferir a sessão e despejar o arquivo com readfile(). É aí que a faixa de bytes morre. Em vez de reimplementar o tratamento de Range no script, deixe o PHP decidir e o Nginx entregar, com X-Accel-Redirect:

location /arquivos-protegidos/ {
    internal;
    alias /srv/videos/;
}
<?php
if (!alunoPodeVer($_SESSION['aluno_id'] ?? null, 'aula-01')) {
    http_response_code(403);
    exit;
}
header('Content-Type: video/mp4');
header('X-Accel-Redirect: /arquivos-protegidos/aula-01.mp4');

A diretiva internal impede que alguém acesse /arquivos-protegidos/ direto. O Nginx recebe o cabeçalho, entrega o arquivo e cuida das faixas. No Apache, o equivalente depende do módulo mod_xsendfile, que não vem instalado por padrão.

Armazenamento de objetos

Em S3 e serviços parecidos, o tipo é gravado junto do objeto no envio. Se o upload não informou, muitos serviços gravam um tipo genérico, e a CDN repete o erro. Envie com o tipo explícito, por exemplo aws s3 cp aula-01.mp4 s3://cursos-videos/aulas/ --content-type video/mp4, e confira de novo com curl -I.

Como preparar o arquivo e a tag

ffprobe -v error -show_entries stream=codec_name -of default=noprint_wrappers=1 aula-01.mp4
ffmpeg -i aula-01.mp4 -c copy -movflags +faststart aula-01-web.mp4

O primeiro comando lista os codecs. Para MP4 que toca em praticamente tudo, a combinação segura é vídeo H.264 com áudio AAC; HEVC, por exemplo, não tem suporte uniforme entre navegadores. O segundo copia as faixas sem recodificar e move o índice moov para o começo, o que faz o play começar sem esperar o fim do arquivo.

<video controls preload="metadata" poster="/aulas/aula-01.jpg" width="1280" height="720">
  <source src="/aulas/aula-01.webm" type="video/webm">
  <source src="/aulas/aula-01.mp4" type="video/mp4">
  <p>Seu navegador não reproduz este vídeo. <a href="/aulas/aula-01.mp4">Baixe a aula em MP4</a>.</p>
</video>

O navegador toca a primeira fonte que entende, e o atributo type evita que ele baixe uma fonte que não sabe tocar. Esse atributo é dica para escolher, não substitui o cabeçalho da resposta. O poster evita o retângulo preto até o primeiro quadro; o guia de miniatura de vídeo sem baixar o arquivo inteiro mostra como gerar essa imagem, e o de controles de vídeo HTML5 trata do player em si.

Erros comuns

  • Servir vídeo como application/octet-stream. Toca num navegador e falha em outro, e o bug parece aleatório.
  • Script que despeja o arquivo sem tratar Range. Não dá para pular para o meio, e cada clique na barra baixa o vídeo de novo.
  • Bloco types parcial no Nginx. Ele substitui a lista padrão e outros tipos, como CSS e imagens, passam a sair errados também.
  • MP4 com o índice no fim. O play demora até o navegador buscar o fim do arquivo.
  • Codec que nem todo navegador decodifica. O tipo está certo, o servidor está certo, e a tela continua preta.
  • Testar só no Chrome do computador. O problema de faixa de bytes aparece primeiro no Safari do iPhone.

Checklist

  • curl -I mostra video/mp4 ou video/webm.
  • Um pedido com Range volta 206 com Content-Range.
  • Vídeo protegido sai por X-Accel-Redirect ou equivalente, não por readfile().
  • Objetos no armazenamento têm o tipo gravado no envio.
  • O MP4 é H.264 com AAC e passou por -movflags +faststart.
  • A tag tem controls, poster e fontes com type.
  • Você testou no Safari do iPhone, arrastando a barra para o meio.

Rode agora o curl com Range: bytes=0-1023 contra um vídeo do seu site. A resposta voltou 206 com 1.024 bytes, ou 200 com o arquivo inteiro?

Avalie este artigo

Referência: http://help.encoding.com/knowledge-base/article/correct-mime-types-for-serving-video-files/