Pular para o conteúdo
Node.js

Servidor RTMP que grava e apaga sozinho

Paloma Macetko
Um unicornio diante de um painel de monitoramento com varias telas de cameras, e uma coruja arquivando rolos de filme em gavetas de cristal enquanto os mais antigos viram poeira de estrelas

Olá meus Unicórnios! 🦄✨

Sabe quando você tem uma câmera e só quer três coisas simples? Ver o que ela está mandando, guardar o que passou, e não acordar um dia com o disco lotado. 😅 Parece pouco. Foi o que eu achei também.

A parte que ninguém conta é que essas três coisas simples são três problemas bem diferentes: receber vídeo ao vivo, cortar esse vídeo em pedaços que dá para manipular, e apagar os pedaços velhos na hora certa. Cada uma tem armadilha própria.

Neste artigo eu monto isso do zero com o MediaMTX — um servidor de vídeo que é um arquivo executável só, sem instalação, sem banco de dados, sem Docker obrigatório — e um pouquinho de Node.js para dar cara de gente ao conjunto. No fim você vai ter um endereço para apontar a câmera, uma pasta se enchendo de arquivos de um minuto, e esses arquivos sumindo sozinhos quando envelhecem.

Tudo o que está aqui foi rodado na minha máquina. Os números, os nomes de arquivo e as saídas de terminal são reais — inclusive um erro que eu tomei no meio do caminho. 😳

Exemplos_ServidorRTMP no GitHub O projeto completo deste artigo: um arquivo de servidor, uma página web para acompanhar e o passo a passo para rodar. github.com

🎥 Primeiro, o que é RTMP (e por que ele ainda existe)

RTMP é o protocolo que a câmera usa para empurrar vídeo para um servidor. Repare no verbo: quem começa a conversa é a câmera, não o servidor. Isso é ótimo, porque a câmera pode estar atrás de qualquer roteador doméstico, sem porta aberta, sem IP fixo — ela sai para a internet e entrega o vídeo.

Ele nasceu com o Flash, morreu com o Flash na hora de exibir vídeo, e continua vivíssimo na hora de enviar. Praticamente toda câmera IP, todo encoder e todo software de transmissão fala RTMP. É o denominador comum.

O endereço tem esta cara:

rtmp://192.168.0.50:1935/portao
       └─ servidor ─┘ └──┘ └───┘
                     porta  nome da camera

Esse último pedaço é só um nome que você escolhe. Cada câmera manda para um nome diferente e o servidor separa tudo. É por ele que os arquivos gravados vão ser organizados depois.

📦 O MediaMTX em uma frase

O MediaMTX é um executável único que recebe vídeo em vários protocolos e reentrega em vários outros. Baixa, descompacta, roda. Não instala nada, não mexe no registro, não precisa de permissão de administrador.

E — esta é a parte que interessa aqui — ele grava em disco sozinho, com corte em segmentos e apagamento automático, sem uma linha de código. É tudo configuração.

⚙️ A configuração inteira, explicada linha por linha

O MediaMTX lê um arquivo .yml. O que vem junto com ele tem mais de 800 linhas de comentário — não se assuste, você não precisa de quase nada. Esta é a configuração completa do nosso servidor:

logLevel: info

# API de status. Fica so no localhost: nao publique esta porta.
api: yes
apiAddress: 127.0.0.1:9997

# A entrada RTMP: e aqui que as cameras publicam.
rtmp: yes
rtmpAddress: :1935

# Este exemplo so precisa de RTMP, entao o resto fica desligado.
rtsp: no
hls: no
webrtc: no
srt: no

pathDefaults:
  record: yes
  recordPath: ./gravacoes/%path/%Y-%m-%d_%H-%M-%S-%f
  recordFormat: fmp4
  recordPartDuration: 1s
  recordSegmentDuration: 60s
  recordDeleteAfter: 24h

# "all_others" aceita qualquer nome de camera que chegar.
paths:
  all_others:

É isso. Vinte e poucas linhas úteis. Vamos pelas que importam:

rtmpAddress: :1935 — a porta onde as câmeras publicam. Repare que não tem endereço antes dos dois-pontos: isso significa "escute em todas as interfaces", que é o que você quer para uma câmera de outra máquina alcançar.

apiAddress: 127.0.0.1:9997 — a API de controle. Aqui eu coloquei o endereço de propósito: com 127.0.0.1 na frente, ela só atende quem está na própria máquina. Essa API não pede senha nenhuma e permite mexer na configuração do servidor inteiro. Deixá-la aberta para a rede é entregar o servidor.

recordPath — o molde do nome dos arquivos. O %path vira o nome da câmera e o resto é data e hora: %Y-%m-%d_%H-%M-%S-%f. A extensão .mp4 o MediaMTX acrescenta sozinho — se você escrever .mp4 aqui, vai acabar com arquivos .mp4.mp4.

recordFormat: fmp4 — grava em MP4 fragmentado. A diferença para o MP4 comum é importante: o MP4 tradicional só fica válido quando o arquivo é fechado corretamente, porque o índice vai no fim. Se faltar luz no meio da gravação, você fica com um arquivo quebrado. O fragmentado vai escrevendo em pedacinhos que já valem sozinhos.

recordPartDuration: 1s — o tamanho desses pedacinhos. É, literalmente, o quanto você perde se a máquina desligar no pior momento possível: um segundo.

recordSegmentDuration: 60s — de quanto em quanto tempo começa um arquivo novo. Guarde este número, porque ele mente um pouquinho e eu volto nele.

recordDeleteAfter: 24h — a retenção. Segmento mais velho que isso é apagado. 0s desliga e nunca apaga nada.

paths: all_others: — aquele "nome da câmera" lá do endereço RTMP. O all_others é o coringa: aceita qualquer nome que chegar. Se você quiser aceitar só nomes específicos, é aqui que lista.

🚀 Rodando e mandando vídeo

Com o arquivo salvo como mediamtx.yml, é um comando:

./mediamtx mediamtx.yml

E a saída real, na minha máquina:

2026/08/05 23:53:00 INF MediaMTX v1.15.1
2026/08/05 23:53:00 INF configuration loaded from mediamtx-gerado.yml
2026/08/05 23:53:00 INF [RTMP] listener opened on :1935
2026/08/05 23:53:00 INF [API] listener opened on 127.0.0.1:9997

Servidor no ar. Agora, para testar sem depender de câmera nenhuma, o ffmpeg gera um vídeo colorido de mentira e publica:

ffmpeg -re -f lavfi -i testsrc=size=640x360:rate=15 \
  -c:v libx264 -preset ultrafast -g 30 -pix_fmt yuv420p \
  -f flv rtmp://localhost:1935/portao

Três detalhes desse comando que valem entender, porque você vai mexer neles:

  • -re manda o ffmpeg enviar na velocidade real, como uma câmera faria. Sem isso ele despeja tudo o mais rápido que conseguir e a gravação sai maluca.
  • -f flv é o formato que o RTMP usa. Não é opcional.
  • -g 30 é o intervalo entre quadros-chave. Com 15 quadros por segundo, dá um quadro-chave a cada 2 segundos. Este número é o que manda no corte dos segmentos, como você vai ver daqui a pouco.

Do outro lado, o servidor reconhece na hora:

23:53:04 INF [RTMP] [conn [::1]:51602] opened
23:53:06 INF [path portao] [recorder] recording 1 track (H264)
23:53:06 INF [RTMP] [conn [::1]:51602] is publishing to path 'portao', 1 track (H264)

E os arquivos começam a aparecer:

gravacoes/portao/2026-08-05_23-53-08-408645.mp4    323.912 bytes
gravacoes/portao/2026-08-05_23-53-24-407203.mp4    325.888 bytes
gravacoes/portao/2026-08-05_23-53-40-406062.mp4    228.470 bytes

Uma câmera de verdade entra no lugar do ffmpeg sem mudar mais nada: é só apontar o campo de RTMP dela para rtmp://<ip-do-servidor>:1935/portao.

⏱️ A primeira mentira: o segmento nunca tem a duração que você pediu

Olhe os horários daqueles três arquivos. Eu tinha configurado segmentos de 15 segundos nesse teste, e olha o que saiu:

23:53:08  →  23:53:24  =  16 segundos
23:53:24  →  23:53:40  =  16 segundos

Dezesseis, não quinze. E não é bug: a própria documentação chama o campo de duração mínima. 🤔

O motivo é que vídeo não pode ser cortado em qualquer ponto. Um arquivo de vídeo precisa começar num quadro-chave — o quadro que se desenha inteiro sozinho, sem depender dos anteriores. Os quadros do meio guardam só as diferenças; se o arquivo começasse num deles, o player não teria de onde partir.

Então o MediaMTX faz o óbvio: cumpre os 15 segundos e espera o próximo quadro-chave para fechar. Como eu mandei um quadro-chave a cada 2 segundos (-g 30 a 15 quadros por segundo), o corte caiu em 16.

🗑️ A retenção: quem apaga é o servidor, não você

Esta é a parte que me surpreendeu para melhor. Eu esperava ter que escrever uma rotina que varre pasta, calcula idade de arquivo e apaga. Não precisa: é uma linha.

recordDeleteAfter: 24h

Para provar que funciona mesmo — e não só na documentação — eu rodei com retenção de 60 segundos e segmentos de 15, contando os arquivos no disco a cada 20 segundos. Esta saída é real:

t+ 20s  1 no disco: 23-54-17
t+ 40s  3 no disco: 23-54-17 23-54-33 23-54-49
t+ 60s  4 no disco: 23-54-17 23-54-33 23-54-49 23-55-05
t+ 80s  5 no disco: 23-54-17 23-54-33 23-54-49 23-55-05 23-55-21
t+100s  4 no disco: 23-54-49 23-55-05 23-55-21 23-55-37
t+120s  4 no disco: 23-55-21 23-55-37 23-55-53 23-56-09
t+140s  5 no disco: 23-55-21 23-55-37 23-55-53 23-56-09 23-56-25

Repare no que acontece entre t+80s e t+100s: os dois arquivos mais antigos (23-54-17 e 23-54-33) somem, e dois novos entram. A partir dali a pasta se mantém sozinha, para sempre, sem ninguém cuidando dela. 🎉

Mas repare também numa segunda coisa, que é o detalhe honesto: a contagem oscila entre 4 e 5, não fica cravada. A limpeza é uma varredura periódica, não um cronômetro por arquivo — o segmento vive um pouquinho além da idade configurada até a próxima passada.

Na prática isso quer dizer uma coisa só, e é importante na hora de comprar disco: dimensione pela retenção mais uma folga. Se a conta der exatamente no limite, um dia ela estoura.

🖥️ Agora o Node.js: mostrando o que está acontecendo

Até aqui não escrevemos código nenhum — e o servidor já grava e já apaga. O Node entra para dar visibilidade: uma página que mostra o que está ao vivo, lista o que foi gravado e deixa assistir.

A primeira função sobe o MediaMTX como processo filho, para você rodar tudo com um node servidor.js só:

function subirMediamtx(cfg, arquivoConfig) {
    if (!fs.existsSync(cfg.executavelMediamtx)) {
        console.error(`\nNao encontrei o MediaMTX em: ${cfg.executavelMediamtx}`);
        console.error('Baixe em https://github.com/bluenviron/mediamtx/releases');
        console.error('e coloque o executavel na pasta deste projeto.\n');
        process.exit(1);
    }

    const processo = spawn(cfg.executavelMediamtx, [arquivoConfig], { stdio: ['ignore', 'pipe', 'pipe'] });

    // spawn com binario ausente emite 'error' e NUNCA 'exit'. Sem este ouvinte,
    // o processo ficaria pendurado esperando um 'exit' que nao vem.
    processo.on('error', (erro) => {
        console.error(`Falha ao iniciar o MediaMTX: ${erro.message}`);
        process.exit(1);
    });

    const repassar = (dados) => {
        String(dados).split('\n').filter(Boolean).forEach((linha) => console.log(`[mediamtx] ${linha}`));
    };
    processo.stdout.on('data', repassar);
    processo.stderr.on('data', repassar);

    processo.on('exit', (codigo) => {
        console.error(`[mediamtx] encerrou com codigo ${codigo}`);
        process.exit(codigo === null ? 1 : codigo);
    });

    return processo;
}

Aquele processo.on('error') parece decoração e não é. Quando o Node não consegue nem iniciar um programa — o executável não está lá, o caminho está errado — ele dispara 'error' e nunca dispara 'exit'. Quem só escuta o 'exit' fica esperando um evento que não vem, e o programa trava sem dizer nada. 😖

🕐 A segunda armadilha: o nome do arquivo e o fuso horário

Para listar as gravações eu preciso transformar 2026-08-05_23-53-08-408645.mp4 numa data. Parece bobo. Não é:

// Os nomes de arquivo saem no formato 2026-08-05_23-38-37-825100.mp4.
// Traduzimos para uma data usando o construtor de hora LOCAL, que e o mesmo
// fuso que o MediaMTX usou para escrever o nome. Repare que nao da para usar
// new Date("2026-08-05...") aqui: com string, o JavaScript assume UTC.
function dataDoNome(nomeArquivo) {
    const partes = nomeArquivo.replace(/\.mp4$/i, '').split('_');
    if (partes.length < 2) return null;

    const [ano, mes, dia] = partes[0].split('-').map(Number);
    const [hora, minuto, segundo] = partes[1].split('-').map(Number);
    if ([ano, mes, dia, hora, minuto, segundo].some(Number.isNaN)) return null;

    return new Date(ano, mes - 1, dia, hora, minuto, segundo);
}

O detalhe cruel está no new Date(ano, mes - 1, dia, ...). Existem duas formas de criar uma data no JavaScript e elas não fazem a mesma coisa:

new Date("2026-08-05")            // meia-noite em UTC
new Date(2026, 7, 5)              // meia-noite no fuso da maquina

Com a string, o JavaScript assume UTC. E o MediaMTX escreveu aquele nome usando a hora local da máquina. Misturar os dois faz o horário deslizar — três horas aqui no Brasil. O vídeo do meio-dia aparece listado às nove da manhã, e você fica procurando bug na gravação quando o bug está na leitura. 😅

Ah, e o mes - 1: nesse construtor os meses começam em zero. Janeiro é 0, agosto é 7. É assim desde 1995 e não vai mudar.

📼 A terceira armadilha: o último arquivo ainda está sendo escrito

Quando você lista a pasta, o arquivo mais recente quase sempre ainda está aberto. O MediaMTX cria o arquivo no começo do intervalo e escreve nele durante o minuto inteiro.

Veja a saída real da minha API no meio de uma gravação — o terceiro arquivo tem quase um terço do tamanho dos outros:

{"camera":"portao","segmentos":[
  {"arquivo":"2026-08-05_23-53-08-408645.mp4","bytes":323912,"gravando":false},
  {"arquivo":"2026-08-05_23-53-24-407203.mp4","bytes":325888,"gravando":false},
  {"arquivo":"2026-08-05_23-53-40-406062.mp4","bytes":228470,"gravando":true}
]}

Ele não é menor porque gravou menos — é menor porque ainda não terminou. Se você anotasse esse tamanho num banco de dados agora e nunca mais olhasse, ficaria com o tamanho errado para sempre. E como o arquivo continua crescendo depois, nada denuncia o erro: os números simplesmente não batem com o disco, e ninguém sabe por quê.

A solução mais simples é olhar a data de modificação e marcar quem foi tocado agora há pouco:

// O ultimo arquivo da lista pode estar sendo escrito agora:
// o MediaMTX mantem o segmento aberto ate fechar o intervalo.
gravando: Date.now() - info.mtimeMs < 5000,

🔒 A quarta armadilha: servir arquivo é abrir uma porta

A página deixa você clicar num segmento e assistir. Ou seja, existe uma rota que recebe um nome de arquivo escolhido por quem acessa e devolve o conteúdo dele. Feito sem cuidado, isso entrega o disco inteiro: basta pedir ../../../etc/passwd.

    app.get('/segmento/:camera/:arquivo', (requisicao, resposta) => {
        const { camera, arquivo } = requisicao.params;

        // Sem esta checagem, um ".." no endereco leria qualquer arquivo do disco.
        // Comparamos os caminhos ja resolvidos, com o separador no fim: sem ele,
        // uma pasta "gravacoes-outra" passaria pelo startsWith de "gravacoes".
        const pastaCamera = path.resolve(cfg.pastaGravacoes, camera);
        const alvo = path.resolve(pastaCamera, arquivo);
        const raiz = path.resolve(cfg.pastaGravacoes) + path.sep;

        if (!alvo.startsWith(raiz) || !alvo.startsWith(pastaCamera + path.sep)) {
            return resposta.status(403).json({ erro: 'caminho invalido' });
        }
        if (!fs.existsSync(alvo)) return resposta.status(404).json({ erro: 'segmento nao existe' });

        return resposta.sendFile(alvo);
    });

O path.resolve() resolve os .. e devolve o caminho final de verdade; depois é só conferir se ele continua dentro da pasta permitida.

E aquele + path.sep no fim não é enfeite. Sem ele a comparação é de texto puro, e texto puro não entende pasta:

permitido:  C:\projeto\gravacoes
pedido:     C:\projeto\gravacoes-outra\segredo.mp4   ← passa!  😱
            └──────── comeca igual ────────┘

Com a barra no fim, gravacoes\ não é mais prefixo de gravacoes-outra\ e a brincadeira acaba. Testei de propósito:

curl "http://localhost:3009/segmento/portao/..%2f..%2fpackage.json"
{"erro":"caminho invalido"}

😳 O erro que eu tomei rodando isto

Se você só for ler um pedaço deste artigo, leia este. 🙏

Na primeira vez que subi o servidor, a página não respondia. Nada de erro na tela, nada de aviso: o navegador só dizia Cannot GET /api/ao-vivo. Fui olhar o log e ele parava na primeira linha, como se o programa tivesse desistido no meio.

O motivo estava na saída de erro, que eu não estava lendo:

node:events:487
      throw er; // Unhandled 'error' event
      ^
Error: listen EADDRINUSE: address already in use :::3009

A porta 3009 já estava ocupada — por uma execução minha, de minutos antes, que eu achava ter encerrado. O Node fez o que faz por padrão com um erro sem tratamento: cuspiu vinte linhas de pilha e morreu. E o "Cannot GET" que eu via no navegador vinha do outro programa, o que estava mesmo segurando a porta. 🤯

Foram dois erros meus numa tacada: não tratar o erro e não ler o stderr. O conserto do primeiro cabe em oito linhas e transforma uma pilha ilegível numa frase que resolve o problema:

    // Sem este ouvinte, uma porta ocupada derruba o Node com um "Unhandled
    // 'error' event" e uma pilha de 20 linhas que nao diz o que fazer.
    const servidor = painel.listen(CONFIG.portaPainel);
    servidor.on('error', (erro) => {
        if (erro.code === 'EADDRINUSE') {
            console.error(`\nA porta ${CONFIG.portaPainel} ja esta em uso.`);
            console.error('Feche o outro programa ou troque "portaPainel" no topo deste arquivo.\n');
        } else {
            console.error(`Erro no painel: ${erro.message}`);
        }
        mediamtx.kill();
        process.exit(1);
    });

É a diferença entre o usuário do seu programa abrir uma issue e ele resolver sozinho em dez segundos.

🛡️ Antes de apontar isto para a internet

O que montamos aqui é material de estudo rodando na sua máquina. Três coisas mudam quando vira gente grande:

A porta 1935 aceita qualquer um. O all_others recebe qualquer nome de câmera, sem senha. Na internet, isso é qualquer pessoa publicando na sua máquina e enchendo seu disco. O MediaMTX tem autenticação — e, antes dela, existe o firewall: libere a 1935 só para os IPs das suas câmeras.

A porta 9997 nunca sai de casa. A API de controle não pede senha e mexe na configuração do servidor. Por isso ela está amarrada em 127.0.0.1 lá na configuração, e é assim que deve ficar.

O RTMP não é criptografado. Quem estiver no caminho vê o vídeo e o nome da câmera passando. Numa rede local entre a câmera e o servidor, tudo bem. Atravessando a internet aberta, não.

🎁 O projeto completo

Está tudo no GitHub: o servidor num arquivo só, a página que mostra as câmeras ao vivo e os segmentos gravados, e o passo a passo para rodar. Dá para clonar, rodar o npm install, e ver funcionando em dois minutos — sem câmera nenhuma, com o ffmpeg fazendo o papel dela.

Exemplos_ServidorRTMP no GitHub Servidor, página web e README com as armadilhas resumidas. Testado com MediaMTX v1.15.1, Node.js 24 e ffmpeg no Windows 10. github.com

O que mais me agradou nessa montagem foi descobrir o quanto dela não é código. A gravação em pedaços e o apagamento automático — as duas coisas que pareciam dar mais trabalho — são seis linhas de configuração. O Node só entrou para mostrar o que já estava acontecendo sem ele.

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

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

Leia também