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
- Pegue o endereço exato do vídeo na aba Rede das ferramentas do navegador.
- Peça só os cabeçalhos e leia o
Content-Type. - Peça o primeiro quilobyte com
Rangee confira se a resposta é 206 comContent-Range. - Se a resposta for 200 com o arquivo inteiro, o servidor ou o script não aceita faixa.
- Confira o codec com
ffprobee 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
typesparcial 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 -Imostravideo/mp4ouvideo/webm.- Um pedido com
Rangevolta 206 comContent-Range. - Vídeo protegido sai por
X-Accel-Redirectou equivalente, não porreadfile(). - 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,postere fontes comtype. - 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?
Referência: http://help.encoding.com/knowledge-base/article/correct-mime-types-for-serving-video-files/