"Necesitamos ver todas las cámaras de seguridad en una sola pantalla" — este es un desafío que enfrenté en un proyecto real.
Obtener streams RTSP de múltiples cámaras IP, convertirlos a HLS con FFmpeg y mostrarlos en un panel basado en navegador. Sin software de vigilancia propietario — solo herramientas de código abierto.
Este artículo cubre la construcción de un panel de vigilancia multicámara, 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.
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: "Entrance",
rtspUrl: process.env.CAM01_RTSP_URL,
hlsDir: "/var/www/hls/cam01",
},
{
id: "cam02",
name: "Server Room",
rtspUrl: process.env.CAM02_RTSP_URL,
hlsDir: "/var/www/hls/cam02",
},
{
id: "cam03",
name: "Parking Lot",
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.
Conclusión
Resumen del panel de vigilancia multicámara construido con FFmpeg y Node.js:
- Arquitectura: Un proceso FFmpeg por cámara para aislamiento de fallos
- Gestión de streams: Node.js maneja la creación, detención y reinicio automático de FFmpeg
- 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 estará disponible en GitHub: omitsu-dev/rtsp-hls-dashboard.
Para asegurar el acceso remoto a este sistema con VPN, estate atento a la próxima guía. Para consultas de despliegue empresarial, contáctanos.
- 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
- Cómo transmitir vídeo HLS con FFmpeg y un CDN — Configuración avanzada de HLS