Puedes construir un panel de vigilancia multicámara ejecutando un proceso FFmpeg por cámara para convertir RTSP a HLS, gestionando esos procesos con Node.js y mostrando los feeds en una cuadrícula del navegador con hls.js. Sin software propietario.
"Necesitamos ver todas las cámaras en una sola pantalla" — este es un desafío que enfrenté hace más de cinco años en un proyecto para una estación de televisión. La solicitud era simple: ocho cámaras en áreas comunes, y un gerente necesitaba monitorear todos los feeds desde un navegador en su escritorio. Un VMS era excesivo para esa escala. Lo construí con FFmpeg, Java y Nginx en aquella época, y me tomó cerca de un mes terminarlo. Este artículo cubre la misma arquitectura usando Node.js.
Este artículo cubre ese sistema desde el diseño de la arquitectura hasta la implementación del frontend, todo ejecutándose en un solo servidor.
Visión general de la arquitectura del sistema
Mapeemos el sistema completo.
Componentes:
| Componente | Función |
|---|---|
| Cámaras IP (múltiples) | Entregan vídeo H.264/H.265 vía RTSP |
| Node.js Stream Manager | Crear y gestionar procesos FFmpeg por cámara |
| FFmpeg (uno por cámara) | Convertir RTSP a HLS en paralelo |
| Nginx | Servir segmentos HLS como archivos estáticos |
| Panel en navegador | Reproducir cada stream HLS con hls.js |
La decisión clave de diseño es ejecutar un proceso FFmpeg por cámara. Aunque un solo proceso FFmpeg puede manejar múltiples entradas, si un stream se congela, puede afectar a los demás. El aislamiento de procesos minimiza el radio de impacto de los fallos.
Convertir múltiples streams RTSP a HLS simultáneamente
Empecemos con comandos FFmpeg directos antes de añadir la capa de gestión en Node.js.
Estructura de directorios
mkdir -p /var/www/hls/{cam01,cam02,cam03}
Comandos FFmpeg por cámara
# Cámara 1 (Hikvision)
ffmpeg -rtsp_transport tcp \
-i "rtsp://admin:pass1@192.168.1.64:554/Streaming/Channels/101" \
-c:v copy -c:a aac -b:a 128k \
-f hls -hls_time 2 -hls_list_size 10 \
-hls_flags delete_segments+append_list \
-hls_segment_filename "/var/www/hls/cam01/seg_%03d.ts" \
"/var/www/hls/cam01/index.m3u8" &
# Cámara 2 (Dahua)
ffmpeg -rtsp_transport tcp \
-i "rtsp://admin:pass2@192.168.1.108:554/cam/realmonitor?channel=1&subtype=0" \
-c:v copy -c:a aac -b:a 128k \
-f hls -hls_time 2 -hls_list_size 10 \
-hls_flags delete_segments+append_list \
-hls_segment_filename "/var/www/hls/cam02/seg_%03d.ts" \
"/var/www/hls/cam02/index.m3u8" &
# Cámara 3 (ONVIF)
ffmpeg -rtsp_transport tcp \
-i "rtsp://admin:pass3@192.168.1.100:554/onvif1" \
-c:v copy -c:a aac -b:a 128k \
-f hls -hls_time 2 -hls_list_size 10 \
-hls_flags delete_segments+append_list \
-hls_segment_filename "/var/www/hls/cam03/seg_%03d.ts" \
"/var/www/hls/cam03/index.m3u8" &
wait
El & ejecuta cada comando en segundo plano, y wait bloquea hasta que todos los procesos terminen. Esto funciona, pero la monitorización, reinicio y gestión de configuración son manuales, por eso lo envolvemos en Node.js para producción.
Estimación de recursos
Con -c:v copy (sin recodificación), el consumo de recursos por stream es mínimo.
| Cámaras | Uso de CPU (aprox.) | Memoria | Ancho de banda |
|---|---|---|---|
| 1-4 | 5-10% | ~50MB/proceso | 2-8 Mbps/cámara |
| 5-10 | 10-25% | ~500MB | 10-40 Mbps |
| 10-20 | 20-50% | ~1GB | 20-80 Mbps |
Si se requiere transcodificación H.265→H.264, la carga de CPU aumenta drásticamente. Considera la aceleración GPU con NVENC o QSV. Consulta la Guía de aceleración GPU para más detalles. La documentación del muxer HLS de FFmpeg cubre todas las opciones disponibles para ajustar el comportamiento de los segmentos.
Construir un gestor de streams con Node.js
Necesitamos un servidor Node.js para crear, detener y monitorizar procesos FFmpeg.
Estructura del proyecto
rtsp-hls-dashboard/
├── server/
│ ├── index.mjs # Punto de entrada
│ ├── stream-manager.mjs # Gestión de procesos FFmpeg
│ └── config.mjs # Configuración de cámaras
├── public/
│ └── index.html # UI del panel
├── package.json
└── .env # Variables de entorno (credenciales)
Configuración de cámaras
// server/config.mjs
export const cameras = [
{
id: "cam01",
name: "Entrada",
rtspUrl: process.env.CAM01_RTSP_URL,
hlsDir: "/var/www/hls/cam01",
},
{
id: "cam02",
name: "Sala de servidores",
rtspUrl: process.env.CAM02_RTSP_URL,
hlsDir: "/var/www/hls/cam02",
},
{
id: "cam03",
name: "Estacionamiento",
rtspUrl: process.env.CAM03_RTSP_URL,
hlsDir: "/var/www/hls/cam03",
},
];
Las URLs RTSP se gestionan vía .env. Nunca incluyas credenciales directamente en el código fuente.
# .env
CAM01_RTSP_URL=rtsp://admin:pass1@192.168.1.64:554/Streaming/Channels/101
CAM02_RTSP_URL=rtsp://admin:pass2@192.168.1.108:554/cam/realmonitor?channel=1&subtype=0
CAM03_RTSP_URL=rtsp://admin:pass3@192.168.1.100:554/onvif1
Gestor de streams
// server/stream-manager.mjs
import { spawn } from "node:child_process";
import { mkdir } from "node:fs/promises";
import path from "node:path";
export class StreamManager {
#processes = new Map();
#restartTimers = new Map();
async startStream(camera) {
if (this.#processes.has(camera.id)) {
console.log(`[${camera.id}] Already running`);
return;
}
await mkdir(camera.hlsDir, { recursive: true });
const args = [
"-rtsp_transport", "tcp",
"-timeout", "5000000",
"-i", camera.rtspUrl,
"-c:v", "copy",
"-c:a", "aac", "-b:a", "128k",
"-f", "hls",
"-hls_time", "2",
"-hls_list_size", "10",
"-hls_flags", "delete_segments+append_list",
"-hls_segment_filename",
path.join(camera.hlsDir, "seg_%03d.ts"),
path.join(camera.hlsDir, "index.m3u8"),
];
const proc = spawn("ffmpeg", args, {
stdio: ["ignore", "pipe", "pipe"],
});
proc.stderr.on("data", (data) => {
const line = data.toString().trim();
if (line.includes("Error") || line.includes("error")) {
console.error(`[${camera.id}] ${line}`);
}
});
proc.on("exit", (code) => {
console.log(`[${camera.id}] FFmpeg exited with code ${code}`);
this.#processes.delete(camera.id);
this.#scheduleRestart(camera);
});
this.#processes.set(camera.id, proc);
console.log(`[${camera.id}] Started (PID: ${proc.pid})`);
}
stopStream(cameraId) {
const proc = this.#processes.get(cameraId);
if (proc) {
proc.kill("SIGTERM");
this.#processes.delete(cameraId);
}
const timer = this.#restartTimers.get(cameraId);
if (timer) {
clearTimeout(timer);
this.#restartTimers.delete(cameraId);
}
}
#scheduleRestart(camera) {
console.log(`[${camera.id}] Restarting in 5 seconds...`);
const timer = setTimeout(() => {
this.#restartTimers.delete(camera.id);
this.startStream(camera);
}, 5000);
this.#restartTimers.set(camera.id, timer);
}
getStatus() {
const status = {};
for (const [id, proc] of this.#processes) {
status[id] = { pid: proc.pid, running: !proc.killed };
}
return status;
}
stopAll() {
for (const [id] of this.#processes) {
this.stopStream(id);
}
}
}
Tres decisiones de diseño clave:
- Aislamiento de procesos: Cada cámara tiene su propio
spawn. Un fallo no afecta a las demás - Reinicio automático: El evento
exitprograma un reinicio a los 5 segundos - Apagado limpio:
SIGTERMpara terminación controlada
Punto de entrada
// server/index.mjs
import "dotenv/config";
import http from "node:http";
import { cameras } from "./config.mjs";
import { StreamManager } from "./stream-manager.mjs";
const manager = new StreamManager();
// Iniciar todos los streams de cámaras
for (const cam of cameras) {
manager.startStream(cam);
}
// API de estado
const server = http.createServer((req, res) => {
if (req.url === "/api/status") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({
cameras: cameras.map((cam) => ({
id: cam.id,
name: cam.name,
hlsUrl: `/hls/${cam.id}/index.m3u8`,
...manager.getStatus()[cam.id],
})),
}));
return;
}
res.writeHead(404);
res.end("Not Found");
});
server.listen(3001, () => {
console.log("Stream manager API running on port 3001");
});
// Apagado controlado
process.on("SIGTERM", () => {
console.log("Shutting down...");
manager.stopAll();
server.close();
process.exit(0);
});
process.on("SIGINT", () => {
console.log("Shutting down...");
manager.stopAll();
server.close();
process.exit(0);
});
Frontend para visualizar las cámaras en el navegador
Un panel simple usando hls.js para mostrar las cámaras en una cuadrícula responsiva.
<!-- public/index.html -->
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Panel de Vigilancia</title>
<script src="https://cdn.jsdelivr.net/npm/hls.js@1"></script>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: #0a0a0a;
color: #e0e0e0;
font-family: "SF Mono", "Fira Code", monospace;
}
.header {
padding: 1rem 2rem;
border-bottom: 1px solid #1a1a1a;
display: flex;
justify-content: space-between;
align-items: center;
}
.header h1 { font-size: 1.2rem; color: #b4f0a0; }
.status { font-size: 0.8rem; color: #666; }
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(480px, 1fr));
gap: 1px;
background: #1a1a1a;
padding: 1px;
}
.camera-cell {
background: #0a0a0a;
position: relative;
}
.camera-cell video {
width: 100%;
display: block;
background: #000;
}
.camera-label {
position: absolute;
top: 8px;
left: 8px;
background: rgba(0, 0, 0, 0.7);
color: #b4f0a0;
padding: 4px 8px;
font-size: 0.75rem;
border: 1px solid #b4f0a033;
}
.camera-status {
position: absolute;
top: 8px;
right: 8px;
width: 8px;
height: 8px;
border-radius: 50%;
background: #4caf50;
}
.camera-status.offline { background: #f44336; }
@media (max-width: 768px) {
.grid { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<div class="header">
<h1>>_ Surveillance Dashboard</h1>
<div class="status" id="clock"></div>
</div>
<div class="grid" id="grid"></div>
<script>
async function init() {
const res = await fetch("/api/status");
const data = await res.json();
const grid = document.getElementById("grid");
for (const cam of data.cameras) {
const cell = document.createElement("div");
cell.className = "camera-cell";
cell.innerHTML = `
<video id="video-${cam.id}" muted autoplay playsinline></video>
<div class="camera-label">${cam.name} [${cam.id}]</div>
<div class="camera-status ${cam.running ? "" : "offline"}"
id="status-${cam.id}"></div>
`;
grid.appendChild(cell);
const video = cell.querySelector("video");
if (Hls.isSupported()) {
const hls = new Hls({
liveSyncDurationCount: 3,
liveMaxLatencyDurationCount: 6,
enableWorker: true,
});
hls.loadSource(cam.hlsUrl);
hls.attachMedia(video);
hls.on(Hls.Events.ERROR, (event, data) => {
if (data.fatal) {
console.error(`[${cam.id}] HLS error:`, data.type);
setTimeout(() => {
hls.loadSource(cam.hlsUrl);
hls.attachMedia(video);
}, 3000);
}
});
} else if (video.canPlayType("application/vnd.apple.mpegurl")) {
video.src = cam.hlsUrl;
}
}
setInterval(() => {
document.getElementById("clock").textContent =
new Date().toLocaleString("es-ES");
}, 1000);
}
init();
</script>
</body>
</html>
Características clave:
- Cuadrícula responsiva:
grid-template-columns: repeat(auto-fit, minmax(480px, 1fr))se adapta al número de cámaras - Baja latencia:
liveSyncDurationCount: 3de hls.js minimiza el retraso - Recuperación de errores: Reconexión automática después de 3 segundos en errores HLS
- Compatible con móviles: Cambia a una sola columna por debajo de 768px
Grabación y limpieza automática
Más allá del streaming en vivo, a menudo necesitas grabación simultánea. El -f tee de FFmpeg permite crear múltiples salidas desde una sola entrada.
Streaming HLS y grabación simultáneos
ffmpeg -rtsp_transport tcp \
-i "rtsp://admin:pass1@192.168.1.64:554/Streaming/Channels/101" \
-c:v copy -c:a aac -b:a 128k \
-f tee -map 0:v -map 0:a \
"[f=hls:hls_time=2:hls_list_size=10:hls_flags=delete_segments+append_list:hls_segment_filename=/var/www/hls/cam01/seg_%03d.ts]/var/www/hls/cam01/index.m3u8|[f=segment:segment_time=3600:segment_format=mp4:reset_timestamps=1:strftime=1]/var/recordings/cam01/%Y%m%d_%H%M%S.mp4"
Esto produce tanto "streaming HLS en vivo" como "grabaciones MP4 por hora" desde un solo proceso FFmpeg.
Eliminación automática de grabaciones antiguas
Gestiona el espacio en disco con un cron job que elimina archivos antiguos.
# /etc/cron.daily/cleanup-recordings
#!/bin/bash
# Eliminar grabaciones de más de 30 días
find /var/recordings/ -name "*.mp4" -mtime +30 -delete
# Registrar la limpieza
echo "[$(date)] Cleanup completed" >> /var/log/recording-cleanup.log
chmod +x /etc/cron.daily/cleanup-recordings
Para más información sobre automatización de operaciones por lotes, consulta Automatizar procesamiento de vídeo por lotes con FFmpeg y Python.
Consideraciones de producción — systemd, logs, monitorización
Servicio systemd
Registra el gestor de streams Node.js como servicio systemd para recuperación automática al reiniciar el servidor.
# /etc/systemd/system/surveillance-dashboard.service
[Unit]
Description=Surveillance Dashboard Stream Manager
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=surveillance
Group=surveillance
WorkingDirectory=/opt/rtsp-hls-dashboard
ExecStart=/usr/bin/node server/index.mjs
Restart=always
RestartSec=10
Environment=NODE_ENV=production
EnvironmentFile=/opt/rtsp-hls-dashboard/.env
# Refuerzo de seguridad
NoNewPrivileges=true
ProtectSystem=strict
ReadWritePaths=/var/www/hls /var/recordings
ProtectHome=true
[Install]
WantedBy=multi-user.target
sudo systemctl enable surveillance-dashboard
sudo systemctl start surveillance-dashboard
sudo systemctl status surveillance-dashboard
Gestión de logs
FFmpeg produce salida verbosa. Usa journald para gestión y filtra por errores.
# Errores en tiempo real
journalctl -u surveillance-dashboard -f | grep -i error
# Última hora de logs
journalctl -u surveillance-dashboard --since "1 hour ago"
Comprobaciones de salud
Un script simple que verifica que la playlist HLS de cada cámara se está actualizando.
#!/bin/bash
# /opt/rtsp-hls-dashboard/healthcheck.sh
CAMERAS=("cam01" "cam02" "cam03")
ALERT_THRESHOLD=30 # segundos
for cam in "${CAMERAS[@]}"; do
playlist="/var/www/hls/${cam}/index.m3u8"
if [ ! -f "$playlist" ]; then
echo "[ALERT] ${cam}: playlist not found"
continue
fi
age=$(( $(date +%s) - $(stat -c %Y "$playlist") ))
if [ "$age" -gt "$ALERT_THRESHOLD" ]; then
echo "[ALERT] ${cam}: playlist is ${age}s old (threshold: ${ALERT_THRESHOLD}s)"
else
echo "[OK] ${cam}: last updated ${age}s ago"
fi
done
Ejecuta esto vía cron e integra con alertas por email o Slack para monitorización en producción.
Para construir una configuración similar en la nube, consulta Construir un servidor de codificación FFmpeg en un VPS.
Configuración de Nginx para servir HLS
El diagrama de arquitectura muestra Nginx sirviendo segmentos HLS. Para que los navegadores reproduzcan los streams correctamente, necesitas tipos MIME y cabeceras CORS específicas.
# /etc/nginx/sites-available/surveillance
server {
listen 8080;
server_name localhost;
location /hls/ {
alias /var/www/hls/;
types {
application/vnd.apple.mpegurl m3u8;
video/mp2t ts;
}
add_header Cache-Control "no-cache, no-store";
add_header Access-Control-Allow-Origin *;
add_header Access-Control-Allow-Methods "GET, OPTIONS";
}
location / {
root /opt/rtsp-hls-dashboard/public;
index index.html;
}
location /api/ {
proxy_pass http://127.0.0.1:3001;
}
}
Cache-Control: no-cache es esencial para streams en vivo. Sin esto, los navegadores cachean playlists .m3u8 obsoletas y el vídeo se congela. La directiva proxy_pass reenvía las peticiones de API al gestor de streams Node.js.
FAQ
¿Cuántas cámaras puede manejar un solo servidor?
Con -c:v copy (sin transcodificación), un servidor modesto (CPU de 4 núcleos, 8 GB de RAM) puede manejar 15-20 cámaras. Cada proceso FFmpeg usa unos 50 MB de memoria y un mínimo de CPU. El cuello de botella suele ser el ancho de banda de red — a 4-8 Mbps por cámara 1080p, 20 cámaras necesitan 80-160 Mbps de throughput sostenido.
¿Cuál es la latencia típica de esta configuración basada en HLS?
Con la configuración de este artículo (hls_time=2, hls_list_size=10, liveSyncDurationCount=3), espera 4-8 segundos de latencia. HLS tiene inherentemente más latencia que protocolos como WebRTC o RTMP porque almacena en búfer múltiples segmentos. Para vigilancia, este retraso suele ser aceptable. Si necesitas latencia inferior a un segundo, considera LL-HLS o un enfoque basado en WebRTC.
¿Funciona con cualquier marca de cámara IP?
Sí — cualquier cámara que soporte RTSP funciona. Hikvision, Dahua, Reolink, Amcrest, Axis y cámaras compatibles con ONVIF producen streams RTSP estándar. Lo único que varía es la ruta de la URL RTSP, que puedes encontrar en la documentación de cada fabricante. Consulta nuestra guía de fundamentos RTSP para patrones de URL comunes por marca.
¿Cuánto almacenamiento necesita la grabación continua?
Una cámara IP 1080p H.264 a 4-8 Mbps genera aproximadamente 1.8-3.6 GB por hora. Para 10 cámaras grabando 24/7 durante 30 días, presupuesta 13-26 TB. Las cámaras H.265 reducen el almacenamiento en aproximadamente un 40%. El script de limpieza con cron de este artículo elimina automáticamente las grabaciones de más de 30 días.
¿Qué pasa cuando una cámara se desconecta?
El StreamManager detecta la salida del proceso FFmpeg y programa automáticamente un reinicio a los 5 segundos. El script de comprobación de salud monitoriza la frescura de la playlist — si no se ha actualizado en 30 segundos, lanza una alerta. En el frontend, el indicador de estado se pone rojo y hls.js reintenta la conexión cada 3 segundos.
¿Puedo acceder al panel remotamente por internet?
Sí, pero nunca lo expongas directamente. Usa una VPN como WireGuard o un proxy inverso con autenticación (autenticación básica de Nginx, Authelia o Cloudflare Access). El panel no tiene autenticación integrada, por lo que asegurar la capa de red es crítico.
¿Necesito el módulo Nginx RTMP?
No. Esta configuración no usa RTMP en absoluto. FFmpeg lee RTSP directamente de las cámaras y escribe segmentos HLS como archivos. Nginx sirve esos archivos como contenido estático simple. El nginx-rtmp-module solo es necesario si estás ingiriendo streams RTMP desde OBS o herramientas similares.
¿Cómo añadir o eliminar cámaras sin reiniciar el servicio?
La implementación actual requiere un reinicio para cambiar la lista de cámaras. Para capacidad de recarga en caliente, extiende la API de estado con endpoints POST /api/cameras que llamen a manager.startStream() o manager.stopStream(). Almacena la configuración de cámaras en un archivo JSON o base de datos en lugar de config.mjs, y vigila los cambios con fs.watch().
Conclusión
El sistema que construí para la estación de televisión funcionó de forma estable con 8 cámaras. Incluso a pequeña escala, el aislamiento de procesos y la recuperación automática demostraron su valor. En r/homelab es un sentimiento común que "FFmpeg + HLS es más flexible que un VMS y escala mejor al añadir cámaras."
Lo que hace que este sistema funcione:
- Arquitectura: Un proceso FFmpeg por cámara. Cuando una cae, las demás siguen funcionando
- Gestión de streams: Node.js maneja la creación, detención y reinicio automático de FFmpeg con reintento a los 5 segundos
- Nginx: Sirve segmentos HLS como archivos estáticos con cabeceras CORS y caché adecuadas
- Frontend: hls.js renderiza los feeds de cámaras en una cuadrícula responsiva
- Grabación:
-f teepara streaming HLS simultáneo con grabación de segmentos MP4 - Operaciones: Servicio systemd + comprobaciones de salud para operación estable en producción
El código fuente de este sistema está disponible en GitHub: omitsu-dev/rtsp-hls-dashboard.
Para consultas de despliegue empresarial, contáctanos.
Artículos relacionados:
- Cómo recibir, convertir y transmitir cámaras RTSP con FFmpeg — Fundamentos de RTSP
- Guía de aceleración GPU de FFmpeg — Manejar múltiples cámaras con codificación hardware
- Cómo transmitir vídeo HLS con FFmpeg y un CDN — Configuración avanzada de HLS y entrega
- Construir un servidor de codificación FFmpeg en un VPS — Despliegue en la nube
- Automatizar procesamiento de vídeo por lotes con FFmpeg y Python — Patrones de automatización por lotes
- Guía de compresión de vídeo con FFmpeg — Optimizar bitrate y tamaño de archivo