Convertendo e otimizando vídeos para MP4 na web
Olá meus Unicórnios! 🦄✨
Sabe quando o cliente manda "só um vidiozinho" pra colocar no site? 😅
Pois é. Chegou um .mov de 120 MB, 4K, direto do celular, e o
site tinha que exibir aquilo numa página que já demorava demais pra
carregar.
A solução óbvia — jogar o arquivo na pasta e apontar a tag
<video> — falha de três formas ao mesmo tempo: o
visitante baixa 120 MB para ver um vídeo de 8 segundos, o player fica
mudo até o download terminar, e no iPhone nem toca. Eu descobri as três
na ordem errada, claro.
Este artigo é o que aprendi montando o conversor de um kit de ferramentas HTTP que eu mantenho, rodando num Windows Server, que converte vídeo de qualquer formato para MP4 otimizado. No fim tem o script completo, autocontido, que você copia e usa hoje — sem API, sem fila, sem banco. Rodei ele antes de publicar e as saídas que você vai ver são as reais do meu terminal. 🙏
🎯 O que "otimizado para web" quer dizer, na prática
Essa expressão é vaga o suficiente pra não significar nada. Quando eu fui espremer, sobraram quatro exigências concretas — e cada uma delas vira uma flag do ffmpeg lá na frente:
- Container MP4 com H.264 e AAC. É o único combo que
toca em absolutamente todo navegador e celular sem plugin. O
.mov, o.mkve o.aviestão fora. - Resolução com teto. 4K num player de 800px de largura é banda jogada fora: o visitante paga por pixels que a tela dele nem consegue mostrar.
- Índice no começo do arquivo. Sem isso o navegador precisa baixar o vídeo inteiro antes do primeiro quadro. Essa é a mais cruel, e vou mostrar ela em detalhe.
- Teto de taxa de bits. Uma cena de ação pode disparar o bitrate e travar o player de quem está no 4G do ônibus.
Repare que nenhuma delas é "comprimir mais". Otimizar vídeo para web não é só deixar o arquivo menor — é deixar ele tocável. Um arquivo de 2 MB que só começa a tocar depois de baixar tudo é pior que um de 5 MB que toca no primeiro segundo.
🛠️ Antes de tudo: instalando o ffmpeg no Windows
Quem faz o trabalho pesado aqui não é o Node — é o ffmpeg. O nosso script só decide o quê pedir a ele. Então vamos instalar primeiro, porque no Windows tem uma pegadinha que trava muita gente. 😅
O jeito mais rápido é pelo winget, que já vem no Windows 10 e 11:
winget install Gyan.FFmpeg
Ele instala e cuida do PATH sozinho. Feche e reabra o
terminal — essa parte é obrigatória, o terminal aberto não enxerga
mudança de PATH feita depois que ele subiu.
Se preferir baixar à mão, ou se estiver num Windows Server sem winget, os dois lugares de onde vale a pena baixar são estes. Não existe "download oficial" com instalador: o projeto distribui código-fonte, e quem monta os executáveis de Windows são estes dois builds, ambos recomendados na própria página do ffmpeg.
Baixe a versão essentials (a full só traz
codecs que você provavelmente não vai usar), descompacte, e mova a pasta
para um lugar definitivo — eu uso C:\ffmpeg. Dentro dela vai
haver uma subpasta bin com ffmpeg.exe,
ffprobe.exe e ffplay.exe. É o caminho
dessa bin que vai no PATH, não o da
pasta de cima.
Colocando no PATH sem sair do terminal
A parte que dá errado. Se você abrir as janelinhas de "Variáveis de
Ambiente" do Windows e colar o caminho, funciona — mas é fácil apagar o
PATH inteiro sem querer naquela caixinha minúscula. 😬 Este
comando do PowerShell faz o mesmo com segurança, porque lê o valor
atual e acrescenta ao fim:
$pasta = 'C:\ffmpeg\bin'
$atual = [Environment]::GetEnvironmentVariable('Path', 'User')
if ($atual -split ';' -contains $pasta) {
'Ja esta no PATH.'
} else {
[Environment]::SetEnvironmentVariable('Path', "$atual;$pasta", 'User')
'Adicionado. Feche e reabra o terminal.'
}
Três detalhes que valem a leitura:
- O
'User'no fim grava noPATHdo seu usuário, que não precisa de administrador. Trocar por'Machine'vale para todos os usuários da máquina, mas aí o terminal precisa estar aberto como administrador. - O
ifcom-containsevita o clássicoPATHcom a mesma pasta repetida sete vezes, de sete tentativas de instalar. 😅 - Use aspas simples no caminho. Com aspas duplas o
PowerShell interpreta a contrabarra em sequências como
\be\t, e o caminho chega torto do outro lado.
Feche e reabra o terminal e confira:
$ ffmpeg -version
ffmpeg version N-122602-g7ebb6c54eb-20260130 Copyright (c) 2000-2026 the FFmpeg developers
🔍 Primeiro passo: descobrir o que você recebeu
Antes de converter qualquer coisa, é preciso saber o que o arquivo é. Quem responde isso é o ffprobe, que vem junto com o ffmpeg e devolve JSON:
const { stdout } = await execFileAsync(FFPROBE, [
'-v', 'quiet',
'-print_format', 'json',
'-show_format',
'-show_streams',
arquivo,
], { maxBuffer: 10 * 1024 * 1024 });
const { streams = [], format = {} } = JSON.parse(stdout);
const video = streams.find((s) => s.codec_type === 'video');
if (!video) throw new Error('O arquivo nao contem stream de video');
Dois detalhes que parecem paranoia e não são. O
maxBuffer generoso existe porque um arquivo com muitos
streams (legendas, faixas de áudio em vários idiomas) gera um JSON bem
maior do que o padrão do Node aguenta — e o erro que você recebe quando
estoura é um ENOBUFS nada óbvio.
E aquele if (!video) não é excesso de zelo: um
MP3 passa pelo ffprobe sem reclamar. Ele é um container
de mídia válido, o comando sai com código 0, o JSON vem bonitinho — só
que sem stream de vídeo nenhum. Sem essa checagem, o ffmpeg é chamado e
falha lá na frente com uma mensagem muito menos clara.
😳 A primeira mentira: format_name não é um nome
Aqui eu perdi um tempo constrangedor. 😅
Meu código precisava saber se o arquivo já era MP4. O ffprobe devolve
um campo chamado format_name, então eu escrevi o que
qualquer pessoa escreveria:
// ERRADO — nunca casa
if (dados.format.format_name === 'mp4') { /* ... */ }
E a condição nunca era verdadeira. Nem para um MP4 que eu tinha acabado de gerar com o próprio ffmpeg. Fui imprimir o valor:
$ ffprobe -v quiet -print_format json -show_format web.mp4
format_name = "mov,mp4,m4a,3gp,3g2,mj2"
Isso mesmo — é uma lista. 🤯 O nome está no singular, mas o ffmpeg agrupa formatos que compartilham o mesmo demuxer, e MOV, MP4, M4A e 3GP são todos variações da mesma família ISO BMFF. Não existe um "nome do formato": existe o conjunto de formatos que aquele demuxer atende.
A correção é trivial depois que se sabe. O que importa é o hábito:
// format_name vem como "mov,mp4,m4a,3gp,3g2,mj2" - e uma LISTA.
formatos: (format.format_name ?? '').split(','),
// e depois:
info.formatos.includes('mp4')
📐 Reduzir resolução: pelo menor lado, sempre
Agora a parte que eu quase implementei errado — e que só não saiu errada porque um vídeo de celular caiu no teste. 🍀
A regra é "no máximo 1080p". A implementação ingênua limita o maior lado: se o maior lado passa de 1080, reduz. Parece certo, mas repare no que acontece com um vídeo vertical de celular, um 2160x3840. O maior lado é 3840, então ele seria reduzido para 608x1080 — um vídeo vertical espremido a um terço da resolução que ele deveria ter.
O motivo é que resolução de vídeo é nomeada pelo menor lado. "1080p" quer dizer 1920x1080 — o número vem da altura, não da largura. Aplicando o limite ao menor lado, a regra vale igualmente para os dois casos:
// Reduz pelo menor lado; nunca amplia. H.264 em yuv420p exige lado par,
// e arredondar para BAIXO mantem o resultado dentro do limite.
function planejarEscala({ largura, altura }) {
const menorLado = Math.min(largura, altura);
if (!menorLado || menorLado <= LIMITE) return null;
const fator = LIMITE / menorLado;
const par = (v) => Math.max(2, Math.floor((v * fator) / 2) * 2);
return { largura: par(largura), altura: par(altura) };
}
Repare que a função devolve null quando não há nada a
fazer, em vez de um objeto com uma flag precisaEscalar. Isso
elimina a flag do resto do código: um null já significa "não
precisa", e quem chama só pergunta se o valor existe.
Com isso, um 3840x2160 deitado vira 1920x1080 e um 2160x3840 em pé vira 1080x1920. Nos dois a densidade percebida é a mesma. Um ultralargo 21:9 de 2560x1080 tem menor lado exatamente 1080 — já está no limite e não é tocado, mesmo com 2560 de largura.
Aquele Math.floor(v / 2) * 2 também não é frescura:
H.264 em yuv420p não codifica lado ímpar. O
formato guarda a informação de cor na metade da resolução, então cada
dimensão precisa ser divisível por 2. Se você mandar 1921 de largura, o
ffmpeg aborta com width not divisible by 2. E o
arredondamento é para baixo de propósito: para cima poderia
estourar o limite que a função inteira existe para respeitar.
E a regra que salva mais banda de todas: vídeo menor que o limite nunca é ampliado. Aumentar resolução não acrescenta detalhe nenhum — o ffmpeg só inventa pixels interpolados a partir dos que já existem. O arquivo cresce, a qualidade não. Um 640x360 continua 640x360.
⚡ A otimização que economiza minutos: não converter
Se você só for ler um pedaço deste artigo, leia este. 🙏
Boa parte dos arquivos que chegam já está pronta. Um MP4 exportado do Premiere em 1080p com H.264 e AAC não precisa de recodificação nenhuma — falta só reorganizar o container. E reorganizar o container é uma operação de cópia de bytes, não de recompressão.
Isso se chama remux, e no ffmpeg é uma flag:
if (copiar) {
args.push('-c', 'copy');
}
O -c copy diz "não decodifique nada, só mova os pacotes
para o container novo". A diferença de tempo não é sutil. Estas são as
duas execuções reais do mesmo script, no mesmo arquivo de 8 segundos:
$ node converter.mjs origem4k.mov web.mp4
origem: mov 3840x2160 h264/aac
convertendo -> 1920x1080 (crf 26)
progresso: 1s (10%)
concluido em 2.6s: 114.6 MB -> 2.7 MB (98% menor)
$ node converter.mjs web.mp4 web2.mp4
origem: mov 1920x1080 h264/aac
copiando streams (remux)
progresso: 8s (99%)
concluido em 0.1s: 2.7 MB -> 2.7 MB (0% menor)
2,6 segundos contra 0,1 segundo — e isso num vídeo de 8 segundos. Num arquivo de 10 minutos, é a diferença entre alguns segundos e vários minutos de CPU a 100%. Se o seu servidor converte vídeos de vários sites, o remux é a diferença entre a fila andar e a fila entupir.
Agora, a decisão de quando copiar tem uma condição que eu esqueci na primeira versão:
// So copia quando ja esta pronto para web. Um MP4 4K tem container e codecs
// certos, mas copia-lo entregaria 4K sem otimizacao - o problema original.
function podeCopiar(info, escala) {
return info.formatos.includes('mp4')
&& info.codecVideo === 'h264'
&& (info.codecAudio === null || info.codecAudio === 'aac')
&& escala === null;
}
A última condição é a que eu não tinha. Sem ela, um MP4 4K — container certo, codecs certos — passava direto pela cópia e era entregue ainda em 4K. O conversor "funcionava", terminava rápido, e devolvia exatamente o arquivo pesado que ele existia para consertar. 😳 Compatibilidade de container e codec não é o mesmo que estar otimizado.
Repare também no codecAudio === null: vídeo sem faixa de
áudio é perfeitamente válido, e recusar a cópia por causa de um áudio
que não existe seria recodificar à toa. Um null ali é
"tudo bem", não "desconhecido".
🎬 As flags do ffmpeg, uma a uma
Este é o comando de recodificação completo. Nenhuma flag aqui é decorativa — cada uma resolve um problema específico:
args.push(
'-c:v', 'libx264', '-crf', String(CRF), '-preset', 'medium',
'-profile:v', 'high',
// yuv420p e o unico pixel format que todo navegador reproduz.
'-pix_fmt', 'yuv420p',
// Teto de taxa com buffer de 2s: contem picos sem inflar cena parada.
'-maxrate', `${MAXRATE}k`, '-bufsize', `${MAXRATE * 2}k`,
'-c:a', 'aac', '-b:a', '128k', '-ac', '2',
);
-crf 26 — o CRF (Constant Rate
Factor) é a qualidade alvo, de 0 (sem perda) a 51 (péssimo). Ele é
melhor que fixar um bitrate porque adapta a compressão à cena: um plano
parado gasta pouco, uma cena de ação gasta mais. Para vídeo de web, 23 é
o padrão do x264 e 26 é uma economia perceptível com perda que quase
ninguém nota num player pequeno. Abaixo de 20 você paga muito por
detalhe que a tela do celular não mostra.
-pix_fmt yuv420p — esta é obrigatória, e
a falta dela dá o bug mais confuso da lista. Se a fonte for
yuv444p ou yuv422p (comum em material
profissional), o x264 preserva o formato, gera um MP4 perfeitamente
válido... que não toca no Safari nem no Chrome. O
arquivo abre no VLC, abre no player do sistema, e na página fica preto. É
o tipo de bug que a gente jura ser do HTML.
-maxrate + -bufsize — o CRF
sozinho não tem teto: numa cena com muito movimento ele pode disparar o
bitrate para manter a qualidade. O maxrate impõe o limite e o
bufsize define em que janela ele é medido. Com
bufsize igual ao dobro do maxrate, a média é
avaliada a cada 2 segundos — folga suficiente para picos curtos, apertado
o bastante para não travar quem está numa conexão lenta.
-ac 2 — força estéreo. Um arquivo com
áudio 5.1 vira MP4 com 6 canais, e o navegador que não sabe fazer o
downmix simplesmente não emite som. Ou pior: emite só os canais
traseiros, e o diálogo — que vive no canal central — some.
-preset medium — a troca entre tempo de
CPU e tamanho do arquivo. O ultrafast converte muito mais
rápido e gera um arquivo bem maior; o veryslow faz o
contrário. Como o arquivo é gerado uma vez e baixado milhares, vale
gastar CPU aqui. O medium é o ponto onde parar de apertar
começa a render pouco.
🚀 +faststart: a flag de uma linha que muda tudo
Guardei a melhor para o fim, porque ela é a que mais gente esquece e a que mais impacto tem na sensação de velocidade.
Um arquivo MP4 é feito de blocos chamados átomos. Dois
importam aqui: o mdat, que são os dados de
vídeo e áudio, e o moov, que é o índice —
duração, resolução, e onde cada quadro está dentro do
mdat.
Sem o índice, o player não sabe nem por onde começar. E, por padrão, o
ffmpeg só consegue escrever o moov depois de
terminar o mdat — afinal, ele só sabe onde os quadros ficaram
quando acabou de escrever todos. Resultado: o índice vai parar no
fim do arquivo.
Que é onde o navegador não consegue chegar sem baixar tudo antes. 😱
Dá para ver a diferença com os próprios olhos. Converti o mesmo vídeo com e sem a flag e pedi ao ffprobe para listar os átomos na ordem em que aparecem:
$ ffprobe -v trace -i sem-faststart.mp4 2>&1 | grep -o "type:'[a-z]*'" | head -6
type:'ftyp'
type:'free'
type:'mdat' <-- 100 MB de video vem primeiro
type:'moov' <-- o indice esta la no fim
type:'mvhd'
type:'trak'
$ ffprobe -v trace -i web.mp4 2>&1 | grep -o "type:'[a-z]*'" | head -6
type:'ftyp'
type:'moov' <-- o indice veio para o comeco
type:'mvhd'
type:'trak'
type:'tkhd'
type:'edts'
É literalmente isso que -movflags +faststart faz: depois
de terminar, o ffmpeg reescreve o arquivo movendo o moov para
a frente. Custa uma passada extra de escrita no disco, e em troca o vídeo
começa a tocar assim que os primeiros segundos chegam, em vez de esperar
os 100 MB.
📊 Progresso: lendo o stderr do ffmpeg
Conversão longa sem indicador de progresso é indistinguível de
processo travado. O ffmpeg publica o andamento, mas de um jeito que pega
gente desprevenida: ele escreve no stderr,
não no stdout. Não é erro — o stdout pode ser o
próprio vídeo, quando a saída é um pipe.
// O ffmpeg emite "time=N/A" nos primeiros quadros: a regex exige digitos.
const RE_PROGRESSO = /time=(\d+):(\d{2}):(\d{2}(?:\.\d+)?)/;
// ...e, dentro do handler de stderr:
const m = RE_PROGRESSO.exec(texto);
if (!m || Date.now() - ultimoAviso < 2000) return;
ultimoAviso = Date.now();
const seg = Number(m[1]) * 3600 + Number(m[2]) * 60 + Number(m[3]);
Aquele comentário é uma cicatriz. 😅 Nos primeiros quadros, antes de
ter um instante válido, o ffmpeg escreve time=N/A. Uma regex
frouxa como /time=(\S+)/ casa com isso, o
Number('N/A') vira NaN, e o progresso aparece
como NaN% logo na primeira linha. Exigir os dígitos no
padrão faz a linha inválida simplesmente não casar.
E tem o cuidado de guardar só o fim do stderr:
// O ffmpeg escreve progresso sem parar; guardamos so o fim, para
// diagnostico em caso de falha.
fimDoStderr = (fimDoStderr + texto).slice(-2000);
Acumular tudo numa string, numa conversão de meia hora, é vazamento de memória disfarçado de log. Os últimos 2000 caracteres bastam: quando o ffmpeg falha, a mensagem útil está sempre no fim.
📜 O script completo
Aqui está tudo junto, autocontido. Salve como
converter.mjs e use assim:
node converter.mjs entrada.mov saida.mp4
Ele assume ffmpeg e ffprobe no
PATH; se não estiverem, aponte com as variáveis
FFMPEG_PATH e FFPROBE_PATH. É este arquivo, sem
uma vírgula de diferença, que produziu as saídas que você viu acima. 🙏
// converter.mjs - converte um video qualquer para MP4 otimizado para web.
// Uso: node converter.mjs entrada.mov saida.mp4
import { execFile, spawn } from 'node:child_process';
import { statSync } from 'node:fs';
import { promisify } from 'node:util';
const execFileAsync = promisify(execFile);
const FFMPEG = process.env.FFMPEG_PATH || 'ffmpeg';
const FFPROBE = process.env.FFPROBE_PATH || 'ffprobe';
const LIMITE = 1080; // teto do MENOR lado - "1080p" vale deitado e em pe
const CRF = 26; // qualidade: menor = melhor e mais pesado
const MAXRATE = 5000; // teto de taxa em kbps, para nao travar em rede lenta
// Descobre formato, codecs e dimensoes do arquivo.
async function inspecionar(arquivo) {
const { stdout } = await execFileAsync(FFPROBE, [
'-v', 'quiet', '-print_format', 'json', '-show_format', '-show_streams',
arquivo,
], { maxBuffer: 10 * 1024 * 1024 });
const { streams = [], format = {} } = JSON.parse(stdout);
const video = streams.find((s) => s.codec_type === 'video');
if (!video) throw new Error('O arquivo nao contem stream de video');
return {
largura: video.width,
altura: video.height,
codecVideo: video.codec_name,
codecAudio: streams.find((s) => s.codec_type === 'audio')?.codec_name ?? null,
duracaoSeg: Number(format.duration) || null,
// format_name vem como "mov,mp4,m4a,3gp,3g2,mj2" - e uma LISTA.
formatos: (format.format_name ?? '').split(','),
};
}
// Reduz pelo menor lado; nunca amplia. H.264 em yuv420p exige lado par,
// e arredondar para BAIXO mantem o resultado dentro do limite.
function planejarEscala({ largura, altura }) {
const menorLado = Math.min(largura, altura);
if (!menorLado || menorLado <= LIMITE) return null;
const fator = LIMITE / menorLado;
const par = (v) => Math.max(2, Math.floor((v * fator) / 2) * 2);
return { largura: par(largura), altura: par(altura) };
}
// So copia quando ja esta pronto para web. Um MP4 4K tem container e codecs
// certos, mas copia-lo entregaria 4K sem otimizacao - o problema original.
function podeCopiar(info, escala) {
return info.formatos.includes('mp4')
&& info.codecVideo === 'h264'
&& (info.codecAudio === null || info.codecAudio === 'aac')
&& escala === null;
}
function montarArgumentos({ entrada, saida, copiar, escala }) {
const args = ['-y', '-i', entrada];
if (copiar) {
args.push('-c', 'copy');
} else {
if (escala) args.push('-vf', `scale=${escala.largura}:${escala.altura}:flags=lanczos`);
args.push(
'-c:v', 'libx264', '-crf', String(CRF), '-preset', 'medium',
'-profile:v', 'high',
// yuv420p e o unico pixel format que todo navegador reproduz.
'-pix_fmt', 'yuv420p',
// Teto de taxa com buffer de 2s: contem picos sem inflar cena parada.
'-maxrate', `${MAXRATE}k`, '-bufsize', `${MAXRATE * 2}k`,
'-c:a', 'aac', '-b:a', '128k', '-ac', '2',
);
}
// Nos DOIS caminhos: move o indice para o inicio do arquivo, para o
// navegador tocar antes de terminar o download.
args.push('-movflags', '+faststart', saida);
return args;
}
// O ffmpeg emite "time=N/A" nos primeiros quadros: a regex exige digitos.
const RE_PROGRESSO = /time=(\d+):(\d{2}):(\d{2}(?:\.\d+)?)/;
function executar(args, duracaoSeg) {
return new Promise((resolve, reject) => {
const filho = spawn(FFMPEG, args, { windowsHide: true });
let fimDoStderr = '';
let ultimoAviso = 0;
// O progresso sai no stderr, nao no stdout - que pode ser o proprio video.
filho.stderr.on('data', (pedaco) => {
const texto = pedaco.toString();
// Guardamos so o fim: acumular tudo e vazamento disfarcado de log.
fimDoStderr = (fimDoStderr + texto).slice(-2000);
const m = RE_PROGRESSO.exec(texto);
if (!m || Date.now() - ultimoAviso < 2000) return;
ultimoAviso = Date.now();
const seg = Number(m[1]) * 3600 + Number(m[2]) * 60 + Number(m[3]);
const pct = duracaoSeg ? ` (${Math.round((seg / duracaoSeg) * 100)}%)` : '';
console.log(` progresso: ${Math.round(seg)}s${pct}`);
});
filho.on('error', (err) => reject(new Error(`ffmpeg nao executou: ${err.message}`)));
filho.on('close', (codigo) => {
if (codigo === 0) return resolve();
console.error(fimDoStderr);
reject(new Error('A conversao do video falhou'));
});
});
}
async function converter(entrada, saida) {
const info = await inspecionar(entrada);
const escala = planejarEscala(info);
const copiar = podeCopiar(info, escala);
console.log(`origem: ${info.formatos[0]} ${info.largura}x${info.altura} `
+ `${info.codecVideo}/${info.codecAudio ?? 'sem audio'}`);
console.log(copiar
? 'copiando streams (remux)'
: `convertendo -> ${escala?.largura ?? info.largura}x${escala?.altura ?? info.altura} (crf ${CRF})`);
const inicio = Date.now();
await executar(montarArgumentos({ entrada, saida, copiar, escala }), info.duracaoSeg);
const mb = (arquivo) => statSync(arquivo).size / 1024 / 1024;
console.log(`concluido em ${((Date.now() - inicio) / 1000).toFixed(1)}s: `
+ `${mb(entrada).toFixed(1)} MB -> ${mb(saida).toFixed(1)} MB `
+ `(${Math.round((1 - mb(saida) / mb(entrada)) * 100)}% menor)`);
}
const [entrada, saida] = process.argv.slice(2);
if (!entrada || !saida) {
console.error('Uso: node converter.mjs entrada.mov saida.mp4');
process.exit(1);
}
converter(entrada, saida).catch((err) => {
console.error(`erro: ${err.message}`);
process.exit(1);
});
🧪 Testando sem precisar de um vídeo
Uma coisa que facilitou demais a minha vida: o ffmpeg
gera vídeo do nada, com o dispositivo virtual
lavfi. Não precisa procurar um 4K de 100 MB para testar:
# um 4K de 8 segundos, com audio, pesado de verdade
ffmpeg -f lavfi -i "mandelbrot=size=3840x2160:rate=30" \
-f lavfi -i "sine=frequency=440" -t 8 \
-c:v libx264 -preset ultrafast -crf 18 -pix_fmt yuv420p \
-c:a aac -y origem4k.mov
# um vertical de celular, para conferir a regra do menor lado
ffmpeg -f lavfi -i "mandelbrot=size=2160x3840:rate=30" -t 3 \
-c:v libx264 -preset ultrafast -pix_fmt yuv420p -y retrato.mp4
Uma dica que custou uma medição errada: o gerador
testsrc, que é o mais citado por aí, produz aquele padrão de
barras coloridas — e barras comprimem maravilhosamente bem. Meu
primeiro "4K" tinha 3 MB, o que fazia qualquer taxa de compressão parecer
irreal. Trocar para mandelbrot, que tem detalhe fino de
verdade, deu um arquivo de 114 MB e números honestos.
E foi assim que confirmei a regra do menor lado no vertical:
$ node converter.mjs retrato.mp4 retrato-web.mp4
origem: mov 2160x3840 h264/sem audio
convertendo -> 1080x1920 (crf 26)
progresso: 3s (98%)
concluido em 1.2s: 34.2 MB -> 0.8 MB (98% menor)
1080x1920, com o menor lado exatamente no limite. 🎯 Repare também no
sem audio: é o caminho do codecAudio === null
sendo exercitado sem que eu precisasse forçar nada.
🏗️ Quando isso vira um serviço
O script acima resolve o caso de rodar na sua máquina. Se você for transformar isso num serviço HTTP — que foi o meu caso — três coisas mudam, e vale saber antes de começar:
Assíncrono, nunca síncrono. O meu conversor fica atrás
de um Cloudflare Tunnel, que encerra requisições por volta de 100
segundos. Um vídeo de 10 minutos estoura isso com folga. Então o
POST devolve 202 na hora com um id de job, e o
resultado vem por polling ou webhook.
Concorrência limitada. O ffmpeg satura CPU por projeto. Sem um limite de execução simultânea, meia dúzia de pedidos ao mesmo tempo derruba o servidor inteiro. Duas conversões em paralelo, com a fila crescendo livremente, foi o que coube na minha máquina.
Escrita atômica. O ffmpeg escreve num diretório
temporário e o arquivo só é movido para a pasta pública depois, com
rename. Sem isso, um site que consultasse no instante errado
baixaria um MP4 pela metade — e rename no mesmo volume é
atômico, então não existe instante intermediário.
Mas nada disso é pré-requisito para começar. O converter.mjs
puro já resolve o "preciso publicar esse vídeo hoje", e a diferença entre
os 120 MB que chegam e os 2,7 MB que saem é a mesma nos dois casos. 💜
✅ O resumo, se você veio direto para cá
format_namedo ffprobe é uma lista ("mov,mp4,m4a,..."). Compare comincludes, nunca com===.- Limite a resolução pelo menor lado — é como resolução é nomeada, e é o que faz a regra valer para vídeo vertical.
- Dimensões pares, arredondando para baixo: H.264 em
yuv420pnão codifica lado ímpar. - Nunca amplie vídeo menor que o limite.
- Remux quando der (
-c copy): 0,1s contra 2,8s no meu teste — mas só quando a resolução também já está dentro do limite. -pix_fmt yuv420psempre, ou o vídeo abre no VLC e fica preto no navegador.-movflags +faststartsempre, nos dois caminhos — é o que move omoovpara o começo e faz o player tocar antes de baixar tudo.- Progresso sai no
stderr, etime=N/Aaparece nos primeiros quadros.
São oito linhas que, somadas, transformam 114,6 MB em 2,7 MB e um player mudo num vídeo que começa na hora. Nada aqui é difícil — é tudo detalhe que só aparece quando morde. 😊
💾 O repositório
O converter.mjs está lá inteiro, do jeito que você leu aqui — e o README traz a tabela do vídeo vertical, que é onde a regra do menor lado se prova:
Por hoje é só, meus unicórnios! 🦄✨
Que a magia do arco-íris continue brilhando em suas vidas! Até mais! 🌈🌟
Leia também
Node.js: alterando o plano de energia do Windows
Como ler e trocar o plano de energia do Windows com Node.js e powercfg: por que resolver por GUID, e o acento que o TextDecoder nao decodifica.
Node.js: exportando grandes volumes do Elasticsearch
Como exportar milhões de registros do Elasticsearch para CSV com Node.js: a parede dos 10.000, a Scroll API e o scroll que fica aberto.
Node.js: gerando certificado SSL e instalando no IIS
Como emitir um certificado gratuito do Let's Encrypt com Node.js e instalar no IIS do Windows, com as armadilhas que travaram tudo.