32blogby Studio Mitsu

Construir un panel de vigilancia multicámara con FFmpeg

Construye un panel de vigilancia en el navegador usando FFmpeg y Node.js. Convierte múltiples feeds RTSP a HLS y muéstralos en una cuadrícula responsiva.

by omitsu15 min read
Contenido

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.

Cam 1HikvisionCam 2DahuaCam 3USB/RPiRTSPStreamManagerNode.js + FFmpegspawn() per cameraHLS-f teeHLS.m3u8 + .tsRecordingMP4 archiveHTTPDashboardhls.js grid view

Componentes:

ComponenteFunción
Cámaras IP (múltiples)Entregan vídeo H.264/H.265 vía RTSP
Node.js Stream ManagerCrear y gestionar procesos FFmpeg por cámara
FFmpeg (uno por cámara)Convertir RTSP a HLS en paralelo
NginxServir segmentos HLS como archivos estáticos
Panel en navegadorReproducir 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

bash
mkdir -p /var/www/hls/{cam01,cam02,cam03}

Comandos FFmpeg por cámara

bash
# 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ámarasUso de CPU (aprox.)MemoriaAncho de banda
1-45-10%~50MB/proceso2-8 Mbps/cámara
5-1010-25%~500MB10-40 Mbps
10-2020-50%~1GB20-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

bash
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

javascript
// 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.

bash
# .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

javascript
// 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 exit programa un reinicio a los 5 segundos
  • Apagado limpio: SIGTERM para terminación controlada

Punto de entrada

javascript
// 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.

html
<!-- 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>&gt;_ 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: 3 de 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

bash
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.

bash
# /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
bash
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.

ini
# /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
bash
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.

bash
# 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.

bash
#!/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.

nginx
# /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 tee para 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: