Vídeo e áudio em HTML: tag video, controls e autoplay
Como colocar vídeo e áudio na página com controles nativos, legenda e poster — e por que o autoplay com som é bloqueado por todo navegador.
Para colocar um vídeo numa página, você escreve <video> com o atributo
controls e o navegador desenha o player inteiro — play, barra de progresso,
volume, tela cheia — sem uma linha de CSS ou de JavaScript. Para som, é
<audio>, com os mesmos atributos. O trabalho de verdade começa depois: qual
formato entregar, quantos bytes a página gasta antes de alguém dar play, e por
que o autoplay com som simplesmente não acontece.
Os exemplos são todos da Escola Clave, uma escola de música online. A aula 1 é um vídeo de 60 segundos sobre o acorde de lá maior; o exercício de ouvido é um áudio de 30 segundos. Os arquivos foram gerados com ffmpeg, servidos por um servidor Node local e abertos no Chrome 151 — todo número desta página saiu dessa medição.
A tag entrega o aparelho; a fonte entrega a mídia
Quando você compra uma televisão, os botões de volume, play e tela cheia fazem
parte do aparelho; o filme é outro item. <video> e <audio> são os aparelhos
de mídia do navegador. O atributo controls pede a interface pronta, enquanto
src ou <source> informa qual arquivo será reproduzido.
O nome técnico é elemento de mídia. Ele não garante que qualquer formato
funcione: o navegador precisa entender o contêiner e o codec, e a rede precisa
entregar os bytes de forma adequada. Antes de estilizar o player, abra a aba
Network, recarregue com preload="metadata" e observe quanto foi baixado sem
dar play. Depois compare com preload="auto". Essa diferença visível mostra
que uma única palavra no HTML também é uma decisão de desempenho.
O player que vem pronto com controls
O elemento mínimo que funciona tem duas linhas:
<video controls>
<source src="/aulas/aula-01-acordes.mp4" type="video/mp4">
</video>controls é um atributo booleano: ele não recebe valor, a presença já liga a
barra de controles. Sem ele, o vídeo aparece na página sem nenhum botão para
tocar — o que só faz sentido em vídeo de fundo, com autoplay e loop. Se
atributo booleano ainda soa estranho, a lição sobre
tags e atributos HTML mostra a anatomia
completa de um elemento.
O primeiro susto costuma ser o tamanho. Coloquei quatro elementos na mesma página, todos sem CSS:
<video id="a" controls></video>
<video id="b" controls preload="metadata">
<source src="/aulas/aula-01-acordes.mp4" type="video/mp4">
</video>
<video id="c" controls preload="none" poster="/aulas/aula-01-capa.jpg">
<source src="/aulas/aula-01-acordes.mp4" type="video/mp4">
</video>
<audio id="d" controls></audio>E medi a caixa de cada um depois que a página terminou de carregar:
for (const id of ['a', 'b', 'c', 'd']) {
const caixa = document.getElementById(id).getBoundingClientRect();
console.log(id, `${caixa.width} x ${caixa.height}`);
}O <video> vazio nasce com 300 × 150 — é o tamanho padrão que a especificação
manda o navegador usar quando ainda não sabe as dimensões do arquivo. Assim que
os metadados chegam, ele salta para o tamanho real do vídeo, 1280 × 720. Esse
salto é o famoso “a página pulou”: o texto que estava abaixo desce de repente.
Repare no elemento c: ele tem preload="none", então nenhum byte de vídeo foi
lido, e mesmo assim já mediu 1280 × 720. Quem deu o tamanho foi o poster, uma
imagem comum de 1280 × 720 — o navegador usa a dimensão dela enquanto não
conhece a do vídeo. O detalhe é que isso vale a partir do momento em que a capa
chega: até lá, o elemento ainda é 300 × 150. Por isso a forma definitiva de
matar o salto é a de baixo.
Dois arquivos, um elemento: quem escolhe a fonte
Dentro de <video> cabem vários <source>. O navegador não baixa todos: ele
testa um por um, na ordem em que aparecem, e para no primeiro que sabe tocar.
<video controls preload="metadata" width="1280" height="720"
poster="/aulas/aula-01-capa.jpg">
<source src="/aulas/aula-01-acordes.webm" type="video/webm">
<source src="/aulas/aula-01-acordes.mp4" type="video/mp4">
<track kind="subtitles" src="/aulas/aula-01-acordes.pt.vtt"
srclang="pt-BR" label="Português" default>
Seu navegador não toca vídeo.
<a href="/aulas/aula-01-acordes.mp4">Baixe a aula</a>.
</video>Abri essa página no Chrome, li o currentSrc do elemento e, do lado do
servidor, listei tudo que o navegador chegou a pedir:
const v = document.querySelector('video');
console.log('currentSrc =', v.currentSrc.replace(location.origin, ''));
console.log('videoWidth =', v.videoWidth);
console.log('videoHeight =', v.videoHeight);
console.log('duration =', v.duration);arquivos que o navegador chegou a pedir: /fontes.html /aulas/aula-01-capa.jpg /aulas/aula-01-acordes.pt.vtt /aulas/aula-01-acordes.webm
O MP4 nunca foi pedido. O Chrome parou no WebM porque ele veio primeiro e o
type batia com algo que ele sabe decodificar. Ou seja: a ordem dos <source>
é a sua ordem de preferência, e o arquivo de baixo é só o plano B.
Quem decide o “sei tocar” é o método canPlayType, e ele responde três coisas
diferentes: "probably", "maybe" ou string vazia.
const v = document.createElement('video');
const tipos = [
'video/mp4',
'video/mp4; codecs="avc1.64001F, mp4a.40.2"',
'video/webm; codecs="vp09.00.31.08, opus"',
'video/ogg; codecs="theora"',
'video/mpeg',
'audio/mpeg',
'audio/ogg; codecs="opus"',
'audio/wav',
];
for (const t of tipos) console.log(t.padEnd(42), '->', JSON.stringify(v.canPlayType(t)));video/mp4 sozinho dá "maybe" porque MP4 é só a embalagem: o que está dentro
pode ser H.264, HEVC, AV1. Quando você declara os codecs, a resposta vira
"probably" — o navegador conseguiu conferir. String vazia é “não sei tocar”, e
foi o que o Theora e o video/mpeg receberam. Repare no segundo: video/mpeg
existe, só que é o MPEG-1/MPEG-2 dos anos 90, não o MP4 — e o Chrome não
decodifica. Guarde esse detalhe, porque ele volta como bug lá embaixo.
O segundo arquivo não é uma gravação nova: o WebM sai do próprio MP4, num comando só.
ffmpeg -i aula-01-acordes.mp4 -c:v libvpx-vp9 -b:v 2200k -row-mt 1 -speed 4 \
-c:a libopus -b:a 128k aula-01-acordes.webm
ls -l aula-01-acordes.mp4 aula-01-acordes.webmMesma duração, mesma resolução, mesmo bitrate pedido: o WebM saiu com 13,7 MB contra 17,5 MB do MP4, 21% menor. É essa diferença que paga o trabalho de manter dois arquivos — e é por isso que o WebM vem primeiro na lista.
| formato | codec de vídeo | onde toca | quando escolher |
|---|---|---|---|
| MP4 | H.264 + AAC | todos os navegadores atuais, TVs, apps | se você só puder ter um arquivo, é este |
| WebM | VP9 + Opus | Chrome, Firefox e Edge há anos; no Safari, tarde e de forma irregular | primeiro <source>, para economizar banda |
| MP4 | AV1 | navegadores recentes, com suporte irregular | catálogo grande, onde 30% de banda pesa |
poster, preload e os bytes que a página gasta sem ninguém dar play
preload é o atributo que decide quanto o navegador baixa antes de qualquer
clique. Ele aceita três valores, e a diferença entre eles não é teoria: dá para
medir.
<video controls width="640" preload="metadata"
poster="/aulas/aula-01-capa.jpg">
<source src="/aulas/aula-01-acordes.mp4" type="video/mp4">
</video>Subi um servidor Node que conta o que sai de fato pelo socket — não o que
foi prometido no Content-Length, porque o navegador aborta a conexão assim que
tem o que queria:
import { createReadStream } from 'node:fs';
const PEDACO = 32 * 1024;
// entrega o arquivo em pedaços, com pausa entre eles, e devolve o que saiu mesmo
async function entregar(res, arquivo, inicio, fim, taxa) {
const espera = (PEDACO / taxa) * 1000;
const fluxo = createReadStream(arquivo, { start: inicio, end: fim, highWaterMark: PEDACO });
let entregue = 0;
for await (const pedaco of fluxo) {
if (res.destroyed) break; // o navegador desistiu: o resto nunca sai do disco
res.write(pedaco);
entregue += pedaco.length; // só conta o que passou pelo socket
await new Promise((r) => setTimeout(r, espera));
}
res.end();
return entregue;
}Sem limitar a banda, o teste não diz nada: em localhost o arquivo inteiro
chega antes de o Chrome ter tempo de desistir. Com a banda travada em 500 KB/s e
a página aberta sem ninguém tocar em nada, o mesmo MP4 de 17.533.382 bytes
consome isto:
Três leituras saem daí:
nonenão baixa nada. Nem os metadados:durationficaNaNe o player não sabe nem quanto o vídeo dura.metadatacustou 196.608 bytes, 1,1% do arquivo — o suficiente para ler duração, resolução e desenhar a barra de progresso no tamanho certo.autopuxou 7,7 MB e só parou quando encheu o buffer do Chrome. Repeti a medição três vezes e o número mal se mexeu (7.700.480, 7.700.480 e 7.733.248 bytes): não é ruído, é o tamanho de buffer que o Chrome persegue. Numa página com quatro aulas listadas, isso é o download de um vídeo inteiro antes de a pessoa escolher qual quer ver.
O padrão que funciona para uma lista de aulas: preload="none" mais um poster
leve. A página fica com a capa certa, no tamanho certo, gastando 48 KB em vez
de 7,7 MB.
Autoplay: o navegador recusa o play() com som, e diz por quê
Escreva autoplay sem mais nada, espere o evento canplay e leia paused um
segundo e meio depois:
<video id="aula" autoplay controls width="640">
<source src="/aulas/aula-01-acordes.mp4" type="video/mp4">
</video>O vídeo carregou, ficou parado no primeiro quadro e não avisou nada. Nenhum erro, nenhum aviso no console. É a falha mais silenciosa desta lição.
Para ver o motivo, é preciso chamar play() na mão e ler a promise que ele
devolve. Este script está dentro da própria página, sem nenhum clique antes:
const v = document.getElementById('aula');
v.addEventListener('canplay', async () => {
try {
await v.play();
console.log('tocou: paused=' + v.paused + ' muted=' + v.muted);
} catch (e) {
console.log(e.name + ': ' + e.message);
}
}, { once: true });Essa é a regra, escrita pelo próprio navegador: som só começa depois que a pessoa interagiu com a página. Não é bug, não é permissão que se peça, não tem como desligar no site publicado. Todo navegador moderno faz igual, porque a alternativa era a web inteira gritando sozinha.
O que passa é mídia muda. Adicione muted e o mesmo código funciona:
<video id="aula" autoplay muted playsinline controls width="640">
<source src="/aulas/aula-01-acordes.mp4" type="video/mp4">
</video>playsinline entra junto por causa do iPhone: sem ele, o iOS abre o vídeo em
tela cheia quando o play começa, e o seu vídeo decorativo de cabeçalho vira um
player ocupando o celular inteiro.
Legenda com track e um arquivo .vtt
Legenda não é imagem queimada no vídeo: é um arquivo de texto separado, no
formato WebVTT. Ele começa com a palavra WEBVTT e uma linha em branco, e
depois é só marcação de tempo:
WEBVTT
00:00:00.000 --> 00:00:04.000
Bem-vindo à aula 1 da Escola Clave.
00:00:04.000 --> 00:00:09.500
Hoje a gente monta o acorde de lá maior no violão.
00:00:09.500 --> 00:00:15.000
Dedo indicador na segunda casa da corda ré.Você conecta esse arquivo com um <track> dentro do <video>:
<track kind="subtitles" src="/aulas/aula-01-acordes.pt.vtt"
srclang="pt-BR" label="Português" default>label é o nome que aparece no menu de legendas do player, srclang é o idioma
e default liga a faixa sem ninguém pedir. Para conferir que carregou mesmo, o
navegador expõe a faixa em video.textTracks:
const faixa = document.querySelector('video').textTracks[0];
console.log('label =', faixa.label);
console.log('idioma =', faixa.language);
console.log('mode =', faixa.mode);
console.log('legendas =', faixa.cues.length);
for (const c of faixa.cues) console.log(` ${c.startTime}s -> ${c.endTime}s "${c.text}"`);mode valendo showing confirma que o default pegou. Se vier disabled, o
arquivo não foi encontrado ou está sendo servido com o tipo errado — o .vtt
precisa sair do servidor como text/vtt.
A legenda exige planejamento e tem utilidade ampla: serve para quem não ouve, para quem está no ônibus sem fone e para quem está estudando em outro idioma. O resto do assunto está na lição de acessibilidade em HTML.
<audio>: o mesmo elemento, sem imagem
O exercício de ouvido da Escola Clave é um acorde de 30 segundos. O markup é o mesmo jogo, trocando a tag:
<audio id="exercicio" controls preload="metadata">
<source src="/aulas/acorde-la-maior.ogg" type="audio/ogg; codecs=opus">
<source src="/aulas/acorde-la-maior.mp3" type="audio/mpeg">
<a href="/aulas/acorde-la-maior.mp3">Baixar o acorde de lá maior</a>
</audio>const a = document.getElementById('exercicio');
console.log('currentSrc =', a.currentSrc.replace(location.origin, ''));
console.log('duration =', a.duration);
console.log('paused =', a.paused);ogg no disco: 437237 bytes baixado com preload=“metadata”: 109557 bytes
Duas coisas mudam em relação ao vídeo. A primeira é o tamanho: <audio controls>
nasce com 300 × 54 e não cresce sozinho — não existe conteúdo visual para
dar dimensão, então largura é decisão sua no CSS. A segunda é que a mesma regra
de autoplay vale aqui, e sem imagem ela machuca mais: um <audio autoplay> com
som não toca, e como não há player para olhar, ninguém descobre o motivo.
Repare que preload="metadata" num arquivo de 437 KB baixou 109.557 bytes — um
quarto do arquivo. Rodei a mesma medição do vídeo nos três valores, e o áudio se
comporta diferente:
auto puxa o arquivo inteiro, e não uma fatia: 30 segundos de áudio cabem no
buffer com folga. Ou seja, em áudio curto auto custa quatro vezes mais que
metadata — mas são 300 KB de diferença, não os 7,5 MB do vídeo. É por isso que
auto num áudio de aula é um pecado pequeno, e num vídeo de aula não é.
O bug que não aparece no console: type errado
Este é o erro que mais consome tempo, porque o navegador não reclama. Um type
errado no <source>:
<video id="v" controls width="640">
<source id="s" src="/aulas/aula-01-acordes.mp4" type="video/mpeg">
</video>O arquivo existe, o caminho está certo, a extensão está certa. Só o type está
errado: MP4 é video/mp4, e video/mpeg é aquele MPEG-1/2 que o canPlayType
respondeu com string vazia lá em cima. Escutei os dois eventos de erro possíveis
e inspecionei o elemento depois de quatro segundos:
const v = document.getElementById('v');
document.getElementById('s').addEventListener('error', () => console.log('error no <source>'));
v.addEventListener('error', () => console.log('error no <video>'));
setTimeout(() => {
console.log('video.error =', v.error);
console.log('networkState =', v.networkState, '(NETWORK_NO_SOURCE =', v.NETWORK_NO_SOURCE + ')');
console.log('currentSrc =', JSON.stringify(v.currentSrc));
}, 4000);Leia com atenção o que não aconteceu. O evento error disparou no
<source>, não no <video>. video.error continua null — o elemento não
considera que houve erro, ele considera que não havia fonte nenhuma. E o
servidor confirmou o resto: o arquivo MP4 nunca chegou a ser pedido. O
type reprovou a fonte antes de qualquer download.
Por isso a lição prática é dupla: escreva o type certo ou não escreva nenhum
(sem type, o navegador baixa o começo do arquivo e descobre sozinho), e
prenda o listener de erro no <source>, não só no <video>.
Quando o problema é o caminho, e não o tipo, o diagnóstico muda de cara:
<video id="v" controls width="640" src="/aulas/aula-02-pestana.mp4"></video>const v = document.getElementById('v');
v.addEventListener('error', () => {
console.log('code =', v.error.code);
console.log('message =', v.error.message);
});Aqui o error disparou no <video>, video.error.code vale 4
(MEDIA_ERR_SRC_NOT_SUPPORTED) e o console traz o
Failed to load resource: 404 junto.
Console vazio aponta para type; console com 404 aponta para caminho.
O servidor precisa entender Range, senão a barra não anda
Player de vídeo não baixa o arquivo do começo ao fim: ele pede pedaços. Quem dá
o comando é o cabeçalho Range, e o servidor tem que responder 206 Partial Content:
curl -i -H "Range: bytes=0-99" \
http://localhost:4180/aulas/aula-01-acordes.mp4 -o /dev/null -D -Com esse servidor, arrastar a barra funciona. Numa página com o vídeo inteiro
num src direto:
<video id="aula" controls width="640" src="/aulas/aula-01-acordes.mp4"></video>coloquei currentTime = 45 assim que os metadados chegaram e olhei os pedidos
que bateram no servidor:
const v = document.getElementById('aula');
v.addEventListener('loadedmetadata', () => {
v.currentTime = 45;
setTimeout(() => console.log('currentTime =', v.currentTime), 3000);
}, { once: true });Range pedidos ao servidor: bytes=0- bytes=12156928-
O segundo pedido pula direto para o byte 12.156.928, que é mais ou menos onde
mora o segundo 45. Agora a mesma página, com o mesmo salto para 45, contra um
servidor ingênuo — um que ignora o cabeçalho Range e sempre devolve o arquivo
inteiro com 200 OK:
import { createServer } from 'node:http';
import { createReadStream, statSync } from 'node:fs';
const MP4 = './publico/aulas/aula-01-acordes.mp4';
createServer((req, res) => {
if (req.url !== '/video.mp4') return res.writeHead(404).end();
const info = statSync(MP4);
console.log(`Range: ${req.headers.range ?? '(nenhum)'} -> 200 OK, ${info.size} bytes`);
res.writeHead(200, { 'Content-Type': 'video/mp4', 'Content-Length': info.size });
createReadStream(MP4).pipe(res);
}).listen(4191);O vídeo toca do começo, a duração é lida — e a barra não anda. currentTime
voltou para 0 porque o navegador pediu para saltar e recebeu o arquivo do
início. Se você está servindo mídia com um servidor escrito à mão, é aqui que
o “meu vídeo não avança” nasce.
Quando não vale hospedar o vídeo você mesmo
Um vídeo de 60 segundos em 720p deu 17,5 MB. Uma aula real de 20 minutos passa de 300 MB, e cada pessoa que assiste baixa isso do seu servidor, na resolução única que você gerou, sem se adaptar à conexão dela.
| situação | o que usar | por quê |
|---|---|---|
| vídeo curto de fundo, sem som | <video muted autoplay loop playsinline> |
é decoração; hospedar você mesmo é simples e barato |
| um punhado de vídeos curtos, públicos | <video> com MP4 no seu servidor |
não vale a complexidade de mais nada |
| catálogo de aulas, várias resoluções | player externo por <iframe> |
quem paga banda e transcodificação é o outro |
| aula paga, com controle de acesso | streaming com URL assinada | <video> sozinho não protege arquivo nenhum |
Para catálogo, a conta quase sempre fecha do lado do serviço externo: ele gera
as versões em 360p, 720p e 1080p, troca de resolução no meio da reprodução e
paga a banda. A troca é o <video> virar um
iframe com o player do YouTube ou do Vimeo — e você
perde o controle sobre a aparência dos controles.
O que vem depois
O próximo passo natural da trilha de HTML é justamente o
<iframe>, que incorpora player, mapa e formulário de fora do seu domínio com
uma tag só. Se quiser ver onde mídia se encaixa no resto da linguagem antes de
seguir, o guia completo de HTML mostra a ordem de estudo
inteira, do doctype ao Open Graph.
Perguntas frequentes
Preciso mesmo gerar o vídeo em MP4 e em WebM?
Dá para tirar o botão de download do player?
Por que o meu vídeo pula para o começo quando arrasto a barra?
Como eu tiro o poster do próprio vídeo?
ffmpeg -ss 3 -i aula.mp4 -frames:v 1 -q:v 2 aula-capa.jpg congela o quadro do terceiro segundo e salva em JPG do tamanho exato do vídeo — no arquivo desta lição, 1280 × 720 em 67 KB. Evite o segundo 0: costuma ser quadro preto ou vinheta.Dúvidas e comentários
Travou em algum passo? Pergunte aqui — a equipe e outros alunos respondem.
Entrar para perguntarÉ o mesmo login gratuito dos cursos.
Nenhuma dúvida por aqui ainda — a primeira pode ser a sua.
Todo o código deste artigo foi executado em Chrome 151.0.7922.170 e Node 24.16.0 no macOS (Apple Silicon), e as saídas exibidas são as reais — como produzimos este conteúdo.
Fontes consultadas
- MDN — O elemento <video> — developer.mozilla.org
- MDN — Guia de autoplay para mídia e Web Audio — developer.mozilla.org
- Chrome for Developers — Autoplay policy in Chrome — developer.chrome.com
- W3C — WebVTT: The Web Video Text Tracks Format — w3.org


