Pular para o conteúdo
Node.js

Convertendo e otimizando vídeos para MP4 na web

Paloma Macetko
Ilustração de um unicórnio operando uma mesa de edição de vídeo mágica: uma fita de filme gigante e escura entra pela esquerda e sai fina e colorida pela direita, enquanto uma coruja com uma varinha mede os quadros

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. 🙏

Exemplos_ConverterOtimizarMP4 no GitHubO conversor completo num arquivo só, sem dependência de npm. É só ter o ffmpeg instalado.github.com

🎯 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:

  1. Container MP4 com H.264 e AAC. É o único combo que toca em absolutamente todo navegador e celular sem plugin. O .mov, o .mkv e o .avi estão fora.
  2. 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.
  3. Í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.
  4. 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.

gyan.dev — builds de Windows (o mesmo do winget)gyan.dev BtbN/FFmpeg-Builds — builds automáticos no GitHubgithub.com

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 no PATH do 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 if com -contains evita o clássico PATH com 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 \b e \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_name do ffprobe é uma lista ("mov,mp4,m4a,..."). Compare com includes, 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 yuv420p nã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 yuv420p sempre, ou o vídeo abre no VLC e fica preto no navegador.
  • -movflags +faststart sempre, nos dois caminhos — é o que move o moov para o começo e faz o player tocar antes de baixar tudo.
  • Progresso sai no stderr, e time=N/A aparece 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:

Exemplos_ConverterOtimizarMP4 no GitHubnode converter.mjs entrada.mov saida.mp4. Um arquivo, zero npm install.github.com

Por hoje é só, meus unicórnios! 🦄✨

Que a magia do arco-íris continue brilhando em suas vidas! Até mais! 🌈🌟

Leia também