Como servir vídeo e áudio direto do storage (range requests)

Para o navegador tocar e avançar um vídeo direto do storage, o servidor deve responder ao cabeçalho Range com 206 Partial Content e anunciar Accept-Ranges: bytes. Sem isso, o vídeo baixa inteiro e o avanço falha.

Atualizado em 6 min de leitura

Objetivos de aprendizado

Depois de ler este artigo, você vai conseguir:

  • Entender como o Range, o 206 e o Content-Range permitem avançar no vídeo
  • Testar com curl se o seu storage ou servidor aceita range requests
  • Corrigir os erros comuns: moov no fim do MP4, 200 em vez de 206 e cache
Nesta página

Você sobe um MP4 de 800 MB para o storage, coloca a URL numa tag <video> e toca. Funciona. Então alguém arrasta a barra para o minuto 40 e o vídeo trava, recomeça do zero ou baixa tudo antes de tocar. Quase sempre o problema não é o arquivo: é que o servidor não está respondendo a range requests.

Os detalhes de protocolo abaixo seguem a RFC 9110 e a documentação da MDN.

NavegadorStorageGET Range: bytes=0-206 Content-Range: bytes 0-…/838860800GET Range: bytes=524288000-206 Content-Range: bytes 524288000-…usuário avançaaula.mp4só os trechos pedidos são baixados
Cada avanço vira um novo GET com Range; o storage responde 206 só com o trecho pedido, sem baixar o arquivo inteiro.

O que é uma range request?#

É uma requisição HTTP que pede só um pedaço do arquivo, em bytes. O cliente manda o cabeçalho Range, e o servidor devolve apenas aquele trecho:

http
GET /videos/aula.mp4 HTTP/1.1
Host: exemplo.com
Range: bytes=1048576-2097151
http
HTTP/1.1 206 Partial Content
Accept-Ranges: bytes
Content-Type: video/mp4
Content-Length: 1048576
Content-Range: bytes 1048576-2097151/838860800

Os quatro elementos que importam:

  • Range: bytes=inicio-fim pede do byte inicio ao fim, ambos inclusos. bytes=0-1023 são os primeiros 1024 bytes. bytes=1000- vai até o fim. bytes=-500 pede os últimos 500 bytes.
  • 206 Partial Content indica que o corpo é só uma parte do arquivo.
  • Content-Range: bytes 1048576-2097151/838860800 diz qual trecho veio e o tamanho total.
  • Accept-Ranges: bytes anuncia que o servidor aceita ranges. Se o cabeçalho não aparece, o cliente não pode contar com esse suporte.

Se o servidor ignora o Range, ele responde 200 OK com o arquivo inteiro. O navegador aceita, mas perde a capacidade de avançar sem baixar tudo.

Por que vídeo e áudio dependem disso?#

A tag <video> (e <audio>) não baixa o arquivo de uma vez para só depois tocar. O navegador pede o início, descobre a duração e o formato, e toca enquanto baixa. Quando o usuário avança, ele calcula o byte correspondente e dispara uma nova requisição com Range.

O atributo preload controla quanto é baixado antes do play: none não baixa nada, metadata baixa só o necessário (duração e dimensões) e auto permite baixar o arquivo todo. É uma dica, e o navegador pode ignorá-la. Para listas com vários vídeos, preload="metadata" evita baixar dezenas de arquivos que ninguém vai tocar:

html
<video controls preload="metadata" width="720" src="https://exemplo.com/videos/aula.mp4"></video>

Sem suporte a ranges, você tem o cenário do começo: barra de progresso que não responde, início lento e muito mais banda consumida que o necessário.

Como testar se o servidor aceita ranges?#

Peça os primeiros bytes com curl e olhe só os cabeçalhos:

bash
curl -s -o /dev/null -D - -H "Range: bytes=0-99" https://exemplo.com/videos/aula.mp4

Uma resposta correta tem este formato:

http
HTTP/2 206
accept-ranges: bytes
content-length: 100
content-range: bytes 0-99/838860800
content-type: video/mp4

Confira três coisas: o status é 206 (não 200), o content-length é 100 e o content-range traz o total. Se vier 200 com o content-length do arquivo inteiro, o servidor ignorou o Range.

A API do S3 também trabalha com ranges: o GetObject aceita o parâmetro Range e devolve só o trecho. Em Python, com boto3:

python
import os

import boto3

s3 = boto3.client(
    "s3",
    endpoint_url=os.environ.get("S3_ENDPOINT"),  # omita no AWS S3
    aws_access_key_id=os.environ["S3_ACCESS_KEY"],
    aws_secret_access_key=os.environ["S3_SECRET_KEY"],
)

r = s3.get_object(Bucket="meu-bucket", Key="videos/aula.mp4", Range="bytes=0-99")
print(r["ResponseMetadata"]["HTTPStatusCode"])  # 206
print(r["ContentRange"])                        # bytes 0-99/838860800
print(len(r["Body"].read()))                    # 100

Como servir vídeo de um bucket privado?#

Se o arquivo não é público, gere uma URL pré-assinada e use-a no src. Por padrão, a URL pré-assinada do S3 assina só o cabeçalho host, e o Range fica fora da assinatura, então o navegador pode pedir qualquer trecho com a mesma URL:

python
url = s3.generate_presigned_url(
    "get_object",
    Params={"Bucket": "meu-bucket", "Key": "videos/aula.mp4"},
    ExpiresIn=6 * 3600,
)

Escolha o ExpiresIn pensando na duração da reprodução, não só no carregamento da página: se a URL expira no meio do vídeo, o próximo avanço falha com 403. Gere a URL quando o usuário abrir o player, não no build do site.

Como servir ranges no seu próprio servidor?#

Se o arquivo está no disco da sua aplicação (ou passa por um proxy seu), você precisa implementar o Range você mesmo. Este servidor Node.js não usa dependências e cobre os formatos inicio-fim, inicio- e -sufixo:

js
import { createReadStream, statSync } from "node:fs";
import { createServer } from "node:http";

const FILE = "video.mp4";

createServer((req, res) => {
  const { size } = statSync(FILE);
  const base = { "Content-Type": "video/mp4", "Accept-Ranges": "bytes" };
  const m = /^bytes=(\d*)-(\d*)$/.exec(req.headers.range ?? "");

  if (!m || (m[1] === "" && m[2] === "")) {
    res.writeHead(200, { ...base, "Content-Length": size });
    return createReadStream(FILE).pipe(res);
  }

  let start, end;
  if (m[1] === "") {
    start = Math.max(size - Number(m[2]), 0); // bytes=-500: últimos 500
    end = size - 1;
  } else {
    start = Number(m[1]);
    end = m[2] === "" ? size - 1 : Math.min(Number(m[2]), size - 1);
  }

  if (start >= size || start > end) {
    res.writeHead(416, { "Content-Range": `bytes */${size}` });
    return res.end();
  }

  res.writeHead(206, {
    ...base,
    "Content-Range": `bytes ${start}-${end}/${size}`,
    "Content-Length": end - start + 1,
  });
  createReadStream(FILE, { start, end }).pipe(res);
}).listen(3000);

Teste com o curl da seção anterior apontando para http://localhost:3000. O exemplo ignora ranges múltiplos (bytes=0-50, 100-150) e responde o arquivo inteiro, o que a especificação permite. Em produção, prefira servir o arquivo pelo storage ou por um servidor web que já implemente isso, em vez de manter este código.

Quais erros evitar?#

  • moov no fim do MP4. Num MP4, o índice do vídeo fica no átomo moov. Se ele está no fim, o navegador precisa buscá-lo antes de tocar. Mova-o para o início sem recodificar: ffmpeg -i entrada.mp4 -c copy -movflags +faststart saida.mp4. A documentação do ffmpeg avisa que esse passo faz uma segunda leitura e não funciona com saída fragmentada.
  • Receber 200 em vez de 206. Alguma camada no caminho (proxy, middleware de compressão, função serverless) está descartando o Range. Teste com curl direto no storage e depois pela URL final para achar onde se perde.
  • Content-Type errado. Um MP4 servido como application/octet-stream pode ser baixado em vez de tocado. Defina video/mp4, video/webm ou audio/mpeg no objeto.
  • Compressão em vídeo. Não aplique gzip ou brotli em MP4: o arquivo já é comprimido, o ganho é mínimo e uma camada de compressão no caminho pode desativar ou quebrar os ranges.
  • URL pré-assinada curta demais. Expira no meio da reprodução e o avanço falha.
  • Arquivo trocado com a mesma URL. Se o objeto muda enquanto o navegador usa trechos em cache, as partes não se encaixam. Use um nome novo por versão (por exemplo aula-v2.mp4) em vez de sobrescrever.
  • Banda sem controle. Cada avanço gera mais requisições. Em storages que cobram saída, vídeo popular vira custo; veja taxas de egress antes de escolher onde hospedar.

Quando usar HLS ou DASH em vez de um MP4?#

Um MP4 com ranges resolve vídeos curtos e médios com uma qualidade só. Se você precisa trocar de resolução conforme a conexão do usuário, ou transmitir ao vivo, o caminho é HLS ou DASH: o vídeo é dividido em segmentos pequenos e uma playlist indica quais baixar. Os segmentos são arquivos comuns, e o object storage serve bem esse papel. Para a maioria dos sites, começar com MP4 e faststart basta.

Como fazer isso na Square Cloud#

O Blob Storage da Square Cloud é um object storage com API compatível com S3, e os downloads respondem a range requests com 206, então o player avança no vídeo sem baixar o arquivo inteiro. Arquivos públicos são servidos por uma CDN global na edge, e cada objeto aceita configurações como Cache-Control e a escolha entre download e exibição inline (útil quando alguém abre a URL do vídeo direto no navegador).

Para arquivos privados, o código boto3 acima funciona com o endpoint https://s3-blob.squarecloud.app, a região auto, o bucket private e as chaves da aba "Acesso S3" do painel: a URL pré-assinada SigV4 pode valer até 7 dias. Pelo painel, você também tem o link temporário de até 24 horas e o link de compartilhamento de até 30 dias, que pode ser revogado. Os arquivos podem ter até 10 GB, o download não tem cobrança de banda, e todo plano inclui uma cota de 5 GB a 250 GB.

Antes de publicar, rode o teste curl -H "Range: bytes=0-99" na URL do seu objeto e confirme o 206 e o Accept-Ranges. É o jeito mais rápido de validar o comportamento com o seu próprio arquivo.

Conheça o Blob Storage

Blob Storage

Object storage compatível com S3, sem taxa de saída

Saiba mais

Perguntas frequentes

Preciso de um servidor de streaming para tocar vídeo no site?

Para um arquivo MP4 ou WebM comum, não. A tag video toca o arquivo por HTTP, desde que o servidor ou o storage responda a range requests. Streaming adaptativo (HLS ou DASH) só é necessário se você quer trocar de qualidade conforme a rede.

O que significa o erro 416 Range Not Satisfiable?

O servidor recebeu um Range que não cabe no arquivo, por exemplo um byte inicial maior que o tamanho do objeto. Ele responde 416 com Content-Range: bytes */tamanho. Em geral indica um arquivo que mudou ou um cálculo de offset errado no cliente.

Por que o vídeo só começa depois de baixar quase tudo?

Num MP4, os metadados ficam no átomo moov. Se ele está no fim do arquivo, o navegador precisa buscá-lo antes de tocar. Reprocesse com ffmpeg -movflags +faststart para movê-lo para o início, sem recodificar.

Funciona com URL pré-assinada?

Sim. O Range é um cabeçalho da requisição e não entra na assinatura de uma URL pré-assinada do S3. O navegador pode enviar Range normalmente, e a URL só precisa durar o tempo da reprodução.

Próximo artigoQuanto custa object storage: armazenamento, requisições e saídaO custo de object storage soma três linhas: GB armazenados, requisições e saída de dados. Veja como cada linha é cobrada, uma conta completa e os custos escondidos.