Gravando em MP4 uma sessão do Puppeteer
Olá meus Unicórnios! 🦄✨
Automação de navegador tem um problema chato: quando ela quebra em produção, às três da manhã, você recebe uma mensagem de erro de uma linha e mais nada. O log diz "seletor não encontrado". Só que por quê? A tela tinha mudado? Apareceu um modal na frente? A sessão caiu e voltou pra tela de login?
Foi por isso que eu passei a gravar em vídeo tudo que o Puppeteer faz. Sabe quando você acha que vai ser meia hora de trabalho? 😅 Pois é. A gravação em si são quatro linhas — o resto do artigo é sobre as armadilhas que descobri depois, e que transformam o vídeo num arquivo que não abre.
Exemplos_Puppeteer_Video no GitHub O código completo deste artigo, com uma página para gravar, assistir e testar a limpeza dos vídeos.🎬 A gravação, em quatro linhas
A biblioteca é a puppeteer-screen-recorder. Ela usa o
Page.startScreencast do protocolo do Chrome (o CDP) para
receber os quadros e vai empurrando tudo para o FFmpeg, que fecha o MP4.
Por isso o FFmpeg precisa estar instalado — sem ele nada
funciona.
const { PuppeteerScreenRecorder } = require("puppeteer-screen-recorder");
const Recorder = new PuppeteerScreenRecorder(page);
await Recorder.start("videos/sessao.mp4");
// ... a automacao acontece aqui ...
await Recorder.stop();
É isso. Se você só for ler um pedaço deste artigo, leia daqui pra baixo 🙏 — porque em produção essas quatro linhas quase nunca bastam.
💥 O npm install que se recusa a rodar
A primeira armadilha aparece antes de qualquer código. Instalando a biblioteca junto com o Puppeteer moderno, o npm simplesmente recusa:
npm error Found: [email protected]
npm error Could not resolve dependency:
npm error peer puppeteer@"19.0.0" from [email protected]
Repare no detalhe cruel: o peer é
puppeteer@"19.0.0" — versão exata, não uma
faixa como ^19. Ou seja, qualquer Puppeteer que não seja
exatamente o 19.0.0 quebra a instalação. E o 19 é de 2022.
A saída é o --legacy-peer-deps:
npm install --legacy-peer-deps
npx puppeteer browsers install chrome
⚙️ A configuração que importa
O construtor aceita um segundo argumento. Estas são as opções que mudam alguma coisa de verdade:
const CONFIG_RECORDER = {
followNewTab: true,
fps: 25,
videoFrame: { width: 1280, height: 720 },
aspectRatio: "16:9",
videoCrf: 18,
videoCodec: "libx264",
videoPreset: "ultrafast",
autopad: { color: "black" }
};
Duas merecem comentário. O followNewTab: sem ele, se a
automação abrir uma aba nova, o vídeo continua filmando a aba
antiga — você grava uma tela parada enquanto a ação
acontece em outro lugar. E o videoCrf, que funciona ao
contrário do que a intuição diz: quanto maior o número,
menor o arquivo e pior a imagem. Como o motivo de gravar é conseguir ler
a mensagem de erro na tela, deixei em 18.
🛡️ Gravar não pode derrubar a automação
Esta foi a primeira lição, e ela é de projeto: o vídeo é um
subproduto. Se o FFmpeg não estiver instalado, o
start() estoura — e seria ridículo o lançamento inteiro
falhar porque a câmera não ligou.
Por isso o início vai num try/catch que só avisa:
var Recorder = null;
var NomeVideoArquivo = null;
try {
NomeVideoArquivo = MontarNomeVideo(Rotulo);
Recorder = new PuppeteerScreenRecorder(page, CONFIG_RECORDER);
await Recorder.start(NomeVideoArquivo);
console.log("Gravacao iniciada:", path.basename(NomeVideoArquivo));
} catch (errRec) {
console.log("Aviso: gravacao nao iniciada:", errRec.message);
Recorder = null;
NomeVideoArquivo = null;
}
var Erro = null;
Repare que no catch eu zero as duas variáveis. Sem isso,
NomeVideoArquivo ficaria preenchido apontando para um
arquivo que nunca chegou a existir — e o código lá embaixo tentaria
salvar esse caminho.
🔒 O stop() mora no finally — e olha o que acontece se não morar
Esta é a parte mais importante do artigo. 🙏
O stop() não é só "parar de gravar": é ele que manda o
FFmpeg escrever o índice final do MP4, a tabela que diz
onde cada quadro começa. Sem esse fecho, o player não sabe abrir o
arquivo.
E aqui está o problema: se a automação estourar no meio,
stop() nunca é chamado. Justamente na execução que
falhou — a única que você realmente queria ver em vídeo.
Então o roteiro roda dentro de um try que captura o erro
sem deixá-lo escapar, e o fecho vai no finally:
try {
await Roteiro(page);
} catch (errRoteiro) {
// Guarda o erro e segue: o finally ainda precisa fechar o video.
Erro = errRoteiro;
console.log("Roteiro falhou:", errRoteiro.message);
} finally {
var GravacaoFinalizadaOk = false;
if (Recorder) {
try {
// Este stop() e' o que escreve o indice final do MP4. Sem ele o
// arquivo existe, tem tamanho, e mesmo assim nao abre em player
// nenhum.
await Recorder.stop();
GravacaoFinalizadaOk = true;
console.log("Gravacao finalizada:", path.basename(NomeVideoArquivo));
} catch (errStop) {
// Quando a sessao morre por timeout do protocolo, o screencast
// trava junto e o stop() estoura aqui. Registrar o motivo real
// separa "o video falhou" de "a automacao falhou" — sao coisas
// diferentes e so este log distingue as duas.
console.log("Aviso: erro ao finalizar gravacao:", errStop.message);
}
}
await Navegador.close().catch(function () { });
Eu quis ver o estrago com meus próprios olhos, então matei o processo no meio de uma gravação — o que um crash ou um timeout fazem na prática:
node tmp-teste/matar.js &
sleep 9
kill -9 $! # mata no meio da gravacao
ls -la morto.mp4
GRAVANDO
Killed
=== arquivo apos kill -9 (sem stop) ===
arquivo nao existe
morto.mp4: No such file or directory
Isso mesmo: o arquivo nem existe. 🤯 Eu esperava
encontrar um MP4 corrompido, de alguns megabytes, que não abrisse. Mas o
FFmpeg estava segurando tudo e o .mp4 só materializa no
fecho. Nove segundos de gravação viraram absolutamente nada.
👻 O vídeo fantasma
A descoberta acima tem uma consequência direta, e é onde eu tinha um bug de verdade: se o arquivo pode não existir, então guardar o caminho dele não prova nada.
Era exatamente o que meu código fazia. Ele salvava o caminho do
.mp4 no banco no fim do processo, sempre. Resultado: registros
apontando para arquivos inexistentes — o painel oferecia um vídeo, o
usuário clicava, e dava 404. Um vídeo fantasma. 😳
A correção é conferir o disco antes de acreditar. Duas condições:
o stop() não pode ter lançado, e o arquivo
tem que existir com tamanho maior que zero:
}
// -----------------------------------------------------------------------
// Video fantasma: so confie no caminho depois de conferir o disco.
// Em timeout o .mp4 as vezes nem chega a ser criado, e guardar o caminho
// geraria um registro apontando para um arquivo que nao existe.
// -----------------------------------------------------------------------
var VideoValido = false;
if (NomeVideoArquivo && GravacaoFinalizadaOk) {
try {
const Estatistica = await fs.stat(NomeVideoArquivo);
VideoValido = Estatistica.isFile() && Estatistica.size > 0;
} catch (errStat) {
console.log("Aviso: video inacessivel:", errStat.message);
VideoValido = false;
}
}
return {
VideoValido: VideoValido,
NomeVideoArquivo: VideoValido ? NomeVideoArquivo : null,
Note que são duas verificações, não uma. Só o
stat() não basta: num timeout do protocolo o screencast trava
junto, o stop() estoura, e mesmo assim pode ter sobrado um
arquivo com bytes dentro — que não abre em player nenhum.
🧹 A limpeza que não limpava
Vídeo ocupa espaço, então tem uma rotina que apaga os MP4 mais velhos
que N dias. Ela é best-effort: erro ali nunca derruba o
servidor, e ela só toca em arquivos .mp4 (a pasta pode ter
outras coisas, e não é papel da limpeza adivinhar o que são).
Escrevi assim, e parecia certo:
const Dias = Number(DiasRetencao) > 0 ? Number(DiasRetencao) : 14;
Aí, testando a página de exemplo, apertei o botão "limpar com 0 dias"
— que deveria apagar tudo — e a resposta veio
{"removidos":0}. Com os arquivos todos lá. 😳
O bug era meu, e é bem bobo: 0 > 0 é falso, então o
0 caía no padrão e virava 14 dias. A rotina
rodava certinho, procurava arquivos com mais de duas semanas, não achava
nenhum e devolvia zero. Nenhum erro, nenhum aviso — só uma limpeza que
não limpa.
// Repare no `>= 0`: com `> 0`, um DiasRetencao igual a 0 cairia no padrao 14
// e a limpeza "apague tudo agora" nao removeria nada — silenciosamente.
const Dias = Number(DiasRetencao) >= 0 ? Number(DiasRetencao) : 14;
Depois da troca, o mesmo botão respondeu {"removidos":2} e
a pasta ficou vazia. É o tipo de erro que só aparece quando você aperta o
botão de verdade — por isso rodar vale mais que reler. 😅
🖥️ A página que prova tudo isso
O repositório vem com uma página onde dá pra ver as armadilhas acontecendo, sem escrever uma linha de código:
O segundo botão é o interessante: ele roda um roteiro que estoura de propósito num seletor inexistente. A resposta mostra o erro e o vídeo válido ao mesmo tempo:
{"videoValido":true,"arquivo":"falha_1785984160296.mp4",
"erro":"Waiting for selector `#elemento-que-nao-existe` failed"}
A automação falhou, e o vídeo está lá — 76 KB, abre no player,
mostra a tela ficando vermelha e a caixa "Vou falhar agora" antes de
tudo parar. Foi o finally que salvou. Clique em "Assistir" e
você vê os últimos segundos antes do erro, que é exatamente o que se
quer às três da manhã. 🌙
Conferindo os dois arquivos com o ffprobe, os dois são
MP4 legítimos:
--- demo_1785983937160.mp4
codec_name=h264 width=1280 height=720 r_frame_rate=25/1
duration=4.040000 size=55048
--- falha_1785983947838.mp4
codec_name=h264 width=1280 height=720 r_frame_rate=25/1
duration=6.000000 size=64632
📦 O código completo
Está tudo no repositório, com a página de demonstração e o README explicando como reproduzir cada armadilha de propósito:
Exemplos_Puppeteer_Video no GitHub Grave uma sessão, quebre-a de propósito, e veja o MP4 sobreviver mesmo assim.Se você já automatiza alguma coisa com o Puppeteer, gravar em vídeo é
das melhores meia-horas que dá pra investir — desde que o
stop() esteja no finally e você confira o disco
antes de confiar no arquivo. O resto é o FFmpeg trabalhando por você. 🎥
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.