32blogby StudioMitsu

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.

10 min read
Contenido

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

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.

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: "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.

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.

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