Pular para o conteúdo
Node.js

Gravando em MP4 uma sessão do Puppeteer

Paloma Macetko
Um unicórnio de crina luminosa segurando uma claquete de cinema diante de uma janela de navegador, com uma coruja segurando um rolo de filme e um castelo mágico ao fundo

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:

Página do exemplo com botões para gravar uma sessão normal ou uma que falha, a lista dos vídeos gravados e o botão de limpeza por retenção

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