32blogby StudioMitsu

FFmpegで複数カメラの監視ダッシュボードを構築する

FFmpegとNode.jsで複数のIPカメラ映像をHLS変換し、ブラウザで一覧表示する監視ダッシュボードの構築方法を実務経験をもとに解説。

13 min read
目次

「社内の監視カメラ映像を1つの画面でまとめて見たい」——これは僕が実務で実際に取り組んだ課題だ。

複数のIPカメラからRTSPでストリームを取得し、FFmpegでHLSに変換し、ブラウザベースのダッシュボードに表示する。専用の監視ソフトを買わなくても、オープンソースの技術スタックだけでここまでできる。

この記事では、1台のサーバーで複数カメラを管理する監視ダッシュボードの構築方法を、アーキテクチャ設計からフロントエンドの実装まで解説する。

システム全体のアーキテクチャ

構築するシステムの全体像を整理しよう。

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

構成要素:

コンポーネント役割
IPカメラ(複数台)RTSPでH.264/H.265映像を配信
Node.js Stream ManagerカメラごとにFFmpegプロセスを起動・管理
FFmpeg(カメラ数分)RTSP→HLS変換を並列実行
NginxHLSセグメントを静的ファイルとして配信
ブラウザダッシュボードhls.jsで各カメラのHLSストリームを再生

重要なのは、FFmpegプロセスをカメラ1台につき1つ起動するということだ。1つのFFmpegプロセスで複数のRTSP入力を扱うこともできるが、1台がフリーズしたときに他のカメラも巻き添えになるリスクがある。プロセスを分離することで障害の影響を最小限に抑える。

複数RTSPストリームを同時にHLS変換する

まずはFFmpegコマンドだけで複数カメラの同時変換を動かしてみよう。Node.js管理サーバーはその後に構築する。

ディレクトリ構造

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

カメラごとのFFmpegコマンド

bash
# カメラ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" &

# カメラ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" &

# カメラ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

& でバックグラウンド実行し、wait で全プロセスの終了を待つ。これでも動くが、プロセスの監視・再起動・設定管理が手動になるため、実運用ではNode.jsで管理する。

リソース見積もり

-c:v copy(再エンコードなし)の場合、1ストリームあたりのリソース消費は非常に小さい。

カメラ数CPU使用率(目安)メモリネットワーク帯域
1-4台5-10%~50MB/プロセス2-8 Mbps/カメラ
5-10台10-25%~500MB10-40 Mbps
10-20台20-50%~1GB20-80 Mbps

H.265→H.264への再エンコードが必要な場合はCPU負荷が大幅に上がる。その場合は GPUエンコードガイド を参照して、NVENCやQSVの活用を検討してほしい。

Node.jsでストリーム管理サーバーを構築する

FFmpegプロセスの起動・停止・死活監視を行うNode.jsサーバーを構築する。

プロジェクト構成

bash
rtsp-hls-dashboard/
├── server/
│   ├── index.mjs          # エントリーポイント
│   ├── stream-manager.mjs # FFmpegプロセス管理
│   └── config.mjs         # カメラ設定
├── public/
│   └── index.html          # ダッシュボードUI
├── package.json
└── .env                    # 環境変数(認証情報)

カメラ設定ファイル

javascript
// server/config.mjs
export const cameras = [
  {
    id: "cam01",
    name: "エントランス",
    rtspUrl: process.env.CAM01_RTSP_URL,
    hlsDir: "/var/www/hls/cam01",
  },
  {
    id: "cam02",
    name: "サーバールーム",
    rtspUrl: process.env.CAM02_RTSP_URL,
    hlsDir: "/var/www/hls/cam02",
  },
  {
    id: "cam03",
    name: "駐車場",
    rtspUrl: process.env.CAM03_RTSP_URL,
    hlsDir: "/var/www/hls/cam03",
  },
];

RTSP URLは .env ファイルで管理する。認証情報をコードにハードコードしてはいけない。

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

ストリームマネージャー

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);
    }
  }
}

ポイントは以下の3つだ。

  • プロセス分離: カメラごとに独立した spawn でFFmpegを起動。1台が落ちても他に影響しない
  • 自動再起動: exit イベントで5秒後に再起動をスケジュール
  • クリーンシャットダウン: SIGTERM で安全に停止

エントリーポイント

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();

// 全カメラのストリームを開始
for (const cam of cameras) {
  manager.startStream(cam);
}

// ステータスAPI
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");
});

// グレースフルシャットダウン
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);
});

ブラウザで映像を一覧表示するフロントエンド

hls.jsを使ってカメラ映像をグリッドレイアウトで表示するシンプルなダッシュボードを作る。

html
<!-- public/index.html -->
<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>監視カメラダッシュボード</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("ja-JP");
      }, 1000);
    }

    init();
  </script>
</body>
</html>

ポイント:

  • レスポンシブグリッド: grid-template-columns: repeat(auto-fit, minmax(480px, 1fr)) でカメラ数に応じて自動的にレイアウトが変わる
  • 低遅延設定: hls.jsの liveSyncDurationCount: 3 で遅延を最小化
  • エラー復帰: HLSエラー発生時に3秒後に自動再接続
  • モバイル対応: 768px以下で1カラムに切り替え

録画と自動クリーンアップの仕組み

ライブ配信だけでなく、録画も同時に行いたいケースは多い。FFmpegの -f tee を使えば、1つの入力から複数の出力を作れる。

HLS配信と録画の同時実行

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"

これで1つのFFmpegプロセスから「HLSライブ配信」と「1時間ごとのMP4録画」が同時に出力される。

古い録画ファイルの自動削除

ディスク容量を管理するために、cronで古いファイルを定期削除する。

bash
# /etc/cron.daily/cleanup-recordings
#!/bin/bash
# 30日以上前の録画ファイルを削除
find /var/recordings/ -name "*.mp4" -mtime +30 -delete

# 削除ログ
echo "[$(date)] Cleanup completed" >> /var/log/recording-cleanup.log
bash
chmod +x /etc/cron.daily/cleanup-recordings

バッチ処理の自動化についてさらに詳しく知りたい場合は FFmpeg × Pythonで動画バッチ処理を自動化する も参考になる。

本番運用のポイント — systemd・ログ・監視

systemdサービス化

Node.jsのストリームマネージャーをsystemdサービスとして登録すれば、サーバー再起動時に自動的にストリームが復旧する。

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

# セキュリティ強化
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

ログ管理

FFmpegは大量のログを出力する。journaldで管理しつつ、エラーだけをフィルタリングする。

bash
# リアルタイムでエラーのみ表示
journalctl -u surveillance-dashboard -f | grep -i error

# 過去1時間のログ
journalctl -u surveillance-dashboard --since "1 hour ago"

ヘルスチェック

各カメラのHLSプレイリストが更新されているか定期的にチェックする簡易スクリプト。

bash
#!/bin/bash
# /opt/rtsp-hls-dashboard/healthcheck.sh
CAMERAS=("cam01" "cam02" "cam03")
ALERT_THRESHOLD=30  # 秒

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

このスクリプトもcronで定期実行し、アラートが出たらメールやSlack通知を送る仕組みにすると安心だ。

クラウド上で同様の構成を組みたい場合は VPSでFFmpegエンコードサーバーを構築する も参考にしてほしい。

まとめ

FFmpegとNode.jsで構築する監視カメラダッシュボードの全体像をまとめると:

  • アーキテクチャ: カメラ1台につきFFmpegプロセス1つを分離して実行。障害の影響範囲を最小化
  • ストリーム管理: Node.jsでFFmpegプロセスの起動・停止・自動再起動を管理
  • フロントエンド: hls.jsでブラウザ上にグリッドレイアウトで映像表示
  • 録画: -f tee でHLS配信と同時にMP4セグメント録画
  • 運用: systemdサービス化 + ヘルスチェックで安定稼働

このシステムのソースコードは GitHub: omitsu-dev/rtsp-hls-dashboard で公開予定だ。

次のステップとして、このシステムをリモートから安全にアクセスする方法が気になる方は、VPNを使ったセキュアなリモートアクセスの記事も準備中だ。企業での導入相談は お問い合わせ からどうぞ。