node-onvif实现摄像机云台控制效果如下

20251103_102528

代码目录结构:
在这里插入图片描述
项目运行之后自动启动webrtc和ptz控制服务,支持局域网摄像头自动搜索,监控视频实时播放,云台控制等功能,这种设计目前非常支持本地开发调试使用,上线后需要node运行webrtc和ptz控制服务
页面代码 PtzController.vue

/* eslint-disable no-undef */
<template>
  <div class="camera-page">
    <h3>ONVIF 摄像机监控与云台控制</h3>

    <div class="camera-container">
      <!-- 左侧视频 -->
      <div class="video-area">
        <video ref="videoElement" autoplay muted playsinline></video>
      </div>

      <!-- 右侧控制面板 -->
      <div class="control-panel">
        <div class="discover-section">
          <el-button type="primary" @click="discoverDevices" :loading="loading">
            {{ loading ? '发现中...' : '发现设备' }}
          </el-button>
        </div>

        <div class="auth-section">
          <label>用户名</label><el-input v-model="username" placeholder="用户名" size="small" clearable></el-input>
          <label>密码</label><el-input v-model="password" placeholder="密码" show-password size="small" clearable></el-input>
        </div>

        <div v-if="devices.length" class="device-list">
          <h3>发现的设备</h3>
          <ul>
            <li v-for="(device, index) in devices" :key="index" class="device-item">
              <span>{{ device.name }} - {{ device.xaddr }}</span>
              <el-button
                  size="mini"
                  :type="selectedDevice === index ? 'success' : 'info'"
                  @click="selectDevice(index)"
              >
                {{ selectedDevice === index ? '已选择' : '选择' }}
              </el-button>
            </li>
          </ul>
        </div>

        <div v-if="selectedDevice !== null" class="ptz-interface">
          <h3>云台控制</h3>
          <div class="ptz-grid">
            <!-- 第一行:上 -->
            <div></div>
            <button class="ptz-btn" @mousedown="startMove(0, 0.5, 0)" @mouseup="stopMove"></button>
            <div></div>
            <button class="ptz-btn" @mousedown="startMove(-0.5, 0, 0)" @mouseup="stopMove"></button>
            <button class="ptz-btn stop" @click="stopMove"></button>
            <button class="ptz-btn" @mousedown="startMove(0.5, 0, 0)" @mouseup="stopMove"></button>
            <div></div>
            <button class="ptz-btn" @mousedown="startMove(0, -0.5, 0)" @mouseup="stopMove"></button>
            <div></div>
          </div>
        </div>
      </div>
    </div>
    <transition name="fade">
      <div v-if="message" class="message" :class="{ error: isError }">
        {{ message }}
      </div>
    </transition>
  </div>
</template>

<script>
import axios from 'axios'
/* global WebRtcStreamer */
import "../utils/webrtcstreamer.js";
import "../utils/adapter.min.js";

export default {
  name: "CameraControl",
  data() {
    return {
      devices: [],
      selectedDevice: null,
      loading: false,
      message: "",
      isError: false,
      username: "admin",//摄像机登录用户名
      password: "admin123456",//摄像机登录密码
      streamer: null,
      rtspUrl: "" // 将根据所选设备自动生成
    };
  },
  methods: {
    async discoverDevices() {
      this.loading = true;
      this.message = "";
      try {
        const res = await axios.get("http://localhost:3000/discover");
        if (res.data.success) {
          this.devices = res.data.devices;
          this.message = `发现 ${this.devices.length} 个设备`;
          this.isError = false;
        } else {
          this.message = "发现设备失败: " + res.data.error;
          this.isError = true;
        }
      } catch (e) {
        this.message = "发现错误: " + e.message;
        this.isError = true;
      } finally {
        this.loading = false;
      }
    },
    async selectDevice(index) {
      this.selectedDevice = index;
      const device = this.devices[index];
      console.log(device)
      // 假设 RTSP URL 以 IP 推断,这里仅示例
    //  const ip = device.xaddr.split("/")[2].split(":")[0];

      const res = await axios.post("http://localhost:3000/getRtsp", {
        deviceIndex: this.selectedDevice,
        username: this.username,
        password: this.password
      });
      console.log(res)
      this.rtspUrl = res.data.data;
      this.initStream(this.rtspUrl);
      this.message = `已选择设备: ${device.name}`;
      this.isError = false;
    },
    initStream(rtspUrl) {
      const videoElement = this.$refs.videoElement;
      if (!videoElement) return;

      if (this.streamer) {
        this.streamer.disconnect();
        this.streamer = null;
      }

      this.streamer = new WebRtcStreamer(videoElement, location.protocol + "//127.0.0.1:8000");
      this.streamer.connect(rtspUrl);
    },
    async startMove(x, y, zoom) {
      if (this.selectedDevice === null) {
        this.message = "请先选择一个设备";
        this.isError = true;
        return;
      }
      try {
        const res = await axios.post("http://localhost:3000/ptz-control", {
          deviceIndex: this.selectedDevice,
          x, y, zoom,
          username: this.username,
          password: this.password
        });
        this.message = res.data.success
            ? `移动 (X:${x}, Y:${y}, Zoom:${zoom})`
            : "移动失败: " + res.data.error;
        this.isError = !res.data.success;
      } catch (e) {
        this.message = "移动错误: " + e.message;
        this.isError = true;
      }
    },
    async stopMove() {
      if (this.selectedDevice === null) return;
      try {
        const res = await axios.post("http://localhost:3000/ptz-stop", {
          deviceIndex: this.selectedDevice,
          username: this.username,
          password: this.password
        });
        this.message = res.data.success ? "停止移动" : "停止失败: " + res.data.error;
        this.isError = !res.data.success;
      } catch (e) {
        this.message = "停止错误: " + e.message;
        this.isError = true;
      }
    }
  },
  beforeDestroy() {
    if (this.streamer) {
      this.streamer.disconnect();
      this.streamer = null;
    }
  }
};
</script>

<style scoped>
.camera-page {
  max-width: 1100px;
  margin: 40px auto;
  background: #fff;
  border-radius: 16px;
  padding: 25px 30px;
  box-shadow: 0 4px 15px rgba(0,0,0,0.1);
  font-family: 'Segoe UI', sans-serif;
}

h1 {
  text-align: center;
  margin-bottom: 25px;
  color: #333;
}

.camera-container {
  display: flex;
  gap: 25px;
}

.video-area {
  flex: 2;
  background: black;
  border-radius: 10px;
  overflow: hidden;
  height: 700px;
}

video {
  width: 100%;
  height: 100%;
  background-color: black;
}

.control-panel {
  flex: 1.2;
  display: flex;
  flex-direction: column;
  align-items: stretch;
  gap: 15px;
}

.device-list ul {
  list-style: none;
  padding: 0;
}

.device-item {
  display: flex;
  justify-content: space-between;
  align-items: center;
  background: #f9fafc;
  border-radius: 6px;
  margin: 6px 0;
  padding: 8px 10px;
}

.ptz-grid {
  display: grid;
  grid-template-columns: repeat(3, 60px);
  grid-template-rows: repeat(3, 60px);
  justify-content: center;
  align-items: center;
  gap: 8px;
  margin: 15px auto;
}

.ptz-btn {
  background-color: #f1f3f5;
  border: 1px solid #dcdfe6;
  border-radius: 50%;
  width: 55px;
  height: 55px;
  font-size: 20px;
  cursor: pointer;
  transition: all 0.2s;
}

.ptz-btn:hover {
  background-color: #e0f2f1;
  transform: scale(1.05);
}

.ptz-btn.stop {
  background-color: #f44336;
  color: #fff;
  font-weight: bold;
}

.zoom-controls {
  text-align: center;
  margin-top: 10px;
}

.message {
  margin-top: 20px;
  text-align: center;
  padding: 10px;
  border-radius: 8px;
}

.message.error {
  background-color: #ffebee;
  color: #c62828;
}
.message:not(.error) {
  background-color: #e8f5e9;
  color: #2e7d32;
}
</style>

ptz服务代码

const express = require('express');
const cors = require('cors');
const onvif = require("node-onvif");
const app = express();
const port = 3000;
app.use(cors());
app.use(express.json());
let devices = []; // 存储发现的设备

// 发现ONVIF设备端点
app.get('/discover', async (req, res) => {
    try {
        console.log('开始设备发现过程');
        const device_info_list = await onvif.startProbe();
        devices = device_info_list.map(info => ({
            urn: info.urn,
            name: info.name,
            xaddr: info.xaddrs[0]
        }));
        console.log(`发现 ${devices.length} 个设备`);
        res.json({ success: true, devices });
    } catch (error) {
        console.error('设备发现失败:', error);
        res.status(500).json({ success: false, error: error.message });
    }
});
//获取视频流地址
app.post('/getRtsp', async (req, res) => {
    const { deviceIndex ,username, password} = req.body;
    try {
        console.log(deviceIndex)
        const device_info = devices[deviceIndex];
        if (!device_info) {
            return res.status(400).json({ success: false, error: '设备未找到' });
        }
        // 创建设备实例
        const device = new onvif.OnvifDevice({
            xaddr: device_info.xaddr,
            user:  username,
            pass:  password
        });
        // 初始化设备
        await device.init();
        const profile = device.getCurrentProfile();
        console.log("profile",profile);
        res.json({ success: true, message: '获取视频流地址成功', data: profile.stream.rtsp });
    } catch (error) {
        console.error('获取视频流地址错误:', error);
        res.status(500).json({ success: false, error: error.message });
    }
});

// PTZ控制端点
app.post('/ptz-control', async (req, res) => {
    const { deviceIndex, x, y, zoom ,username, password} = req.body;
    try {
        const device_info = devices[deviceIndex];
        if (!device_info) {
            return res.status(400).json({ success: false, error: '设备未找到' });
        }
        // 创建设备实例
        const device = new onvif.OnvifDevice({
            xaddr: device_info.xaddr,
            user:  username,
            pass:  password
        });
        // 初始化设备
        await device.init();
        const profile = device.getCurrentProfile();
        const profileToken = profile.token;
        const result = await device.ptzMove({
            'profileToken': profileToken,
            'speed': { x: x || 0, y: y || 0, z: zoom || 0 }
        });
        res.json({ success: true, message: 'PTZ指令发送成功', data: result });
    } catch (error) {
        console.error('PTZ控制错误:', error);
        res.status(500).json({ success: false, error: error.message });
    }
});

// 停止PTZ运动
app.post('/ptz-stop', async (req, res) => {
    const { deviceIndex,username, password } = req.body;
    try {
        const device_info = devices[deviceIndex];
        if (!device_info) {
            return res.status(400).json({ success: false, error: '设备未找到' });
        }
        const device = new onvif.OnvifDevice({
            xaddr: device_info.xaddr,
            user :  username,
            pass :  password
        });
        await device.init();
        const result = await device.ptzStop();
        res.json({ success: true, message: 'PTZ运动已停止', data: result });
    } catch (error) {
        console.error('停止PTZ错误:', error);
        res.status(500).json({ success: false, error: error.message });
    }
});
app.listen(port, () => {
    console.log(`Node.js ONVIF服务器运行在 http://localhost:${port}`);
});


start-stream.js代码

const { spawn } = require('child_process')
const path = require('path')

const streamerPath = path.resolve(__dirname, '../webrtc-stream/webrtc-streamer.exe')

// 摄像头 RTSP 地址 这个不用改是启动必要数据随便写的
const rtspUrl = 'rtsp://192.168.1.11:554/ch=1&subtype=*'

console.log('启动 webrtc-streamer...')
const streamer = spawn(streamerPath, [
     rtspUrl,         //  RTSP
     '-p', '8000'    // HTTP 端口
], { stdio: 'inherit' })

streamer.on('exit', code =>
    streamer.kill()
);

package.json

{
  "name": "rtspplay",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    /*这里是自动启动摄像头流和云台控制服务*/
    "serve": "concurrently \"node ./camera/webrtc-stream/start-stream.js\" \"node ./camera/ptz/server.js\" \"vue-cli-service serve\"",
    "build": "vue-cli-service build",
    "lint": "vue-cli-service lint"
  },
  "dependencies": {
    "axios": "^1.13.1",
    "body-parser": "^2.2.0",
    "core-js": "^3.8.3",
    "cors": "^2.8.5",
    "express": "^5.1.0",
    "node-onvif": "^0.1.7",
    "element-ui": "2.15.14",
    "node-rtsp-stream": "^0.0.9",
    "onvif": "^0.8.1",
    "vue": "^2.6.14",
    "ws": "^8.18.3"
  },
  "devDependencies": {
    "@babel/core": "^7.12.16",
    "@babel/eslint-parser": "^7.12.16",
    "@vue/cli-plugin-babel": "~5.0.0",
    "@vue/cli-plugin-eslint": "~5.0.0",
    "@vue/cli-service": "~5.0.0",
    "concurrently": "^9.2.1",
    "eslint": "^7.32.0",
    "eslint-plugin-vue": "^8.0.3",
    "vue-template-compiler": "^2.6.14"
  },
  "eslintConfig": {
    "root": true,
    "env": {
      "node": true
    },
    "extends": [
      "plugin:vue/essential",
      "eslint:recommended"
    ],
    "parserOptions": {
      "parser": "@babel/eslint-parser"
    },
    "rules": {}
  },
  "browserslist": [
    "> 1%",
    "last 2 versions",
    "not dead"
  ]
}

Logo

火山引擎开发者社区是火山引擎打造的AI技术生态平台,聚焦Agent与大模型开发,提供豆包系列模型(图像/视频/视觉)、智能分析与会话工具,并配套评测集、动手实验室及行业案例库。社区通过技术沙龙、挑战赛等活动促进开发者成长,新用户可领50万Tokens权益,助力构建智能应用。

更多推荐