Servidor RTMP que grava e apaga sozinho
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. 😳
🎥 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:
-remanda 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.
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
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.