服务端代码
- 下载ffmpeg,并且配置环境变量
创建vue转换工程,创建package.json文件
{
"name": "rtsp-server",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"build": "babel src/index.js -d dist",
"start": "npm run build && node dist/index.js",
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"express": "^4.17.1",
"express-ws": "^4.0.0",
"ffmpeg-static": "^5.1.0",
"fluent-ffmpeg": "^2.1.2",
"websocket-stream": "^5.5.0"
},
"devDependencies": {
"@babel/cli": "^7.6.2",
"@babel/core": "^7.6.2",
"@babel/preset-env": "^7.6.2"
}
}
创建index.js处理逻辑
const express = require('express');
const expressWebSocket = require("express-ws");
import webSocketStream from "websocket-stream/stream";
// 在需要使用FFmpeg的地方使用ffmpegPath变量
const ffmpeg = require('fluent-ffmpeg');
// 注意一下:配置ffmpeg程序的路径(包含exe程序名)
ffmpeg.setFfmpegPath('D:/quniao/doc/ffmpeg/9-git-159b028df5-full_build/bin/ffmpeg');
function localServer() {
let app = express();
app.use(express.static(__dirname));
// extend express app with app.ws()
expressWebSocket(app, null, {
perMessageDeflate: true
});
app.ws("/rtsp/:id/", rtspRequestHandle)
app.listen(8888);
console.log("express listened")
}
function rtspRequestHandle(ws, req) {
console.log("rtsp request handle");
// convert ws instance to stream
const stream = webSocketStream(ws, {
binary: true,
browserBufferTimeout: 1000000
}, {
browserBufferTimeout: 1000000
});
let url = req.query.url;
console.log("rtsp url:", url);
console.log("rtsp params:", req.params);
// ffmpet转码
let ffmpegCommand = ffmpeg(url)
.addInputOption("-rtsp_transport", "tcp", "-buffer_size", "102400") // 这里可以添加一些 RTSP 优化的参数
.outputOptions([
'-c:v libx264', // 设置视频编码器
'-c:a aac', // 设置音频编码器
'-b:v 2000k', // 设置视频比特率
'-s 1280x720', // 设置视频分辨率
'-b:a 128k', // 设置音频比特率
'-ar 44100' // 设置音频采样率
])
.on("start", function () {
console.log(url, "Stream started.");
ws.send('')
})
.on("codecData", function () {
console.log(url, "Stream codecData.")
// 摄像机在线处理
})
.on("error", function (err) {
console.log(url, "An error occured: ", err.message);
stream.end();
})
.on("end", function () {
console.log(url, "Stream end!");
stream.end();
// 摄像机断线的处理
})
.outputFormat("flv").videoCodec("copy").noAudio(); // 输出格式flv 无音频
stream.on("close", function () {
ffmpegCommand.kill('SIGKILL');
});
try {
ffmpegCommand.pipe(stream);
} catch (error) {
console.log(error);
}
}
localServer()
服务过程创建完成后的结构:
客户端代码
执行命令安装 "flv.js": "^1.6.2",
<template>
<div style="background-color: #ececec; width: 100%; height: 100%; padding: 20px">
<a-row :gutter="24">
<a-col :span="14">
<a-card :bordered="false" class="table-container">
<div>
<video ref="player" class="video" muted></video>
</div>
</a-card>
</a-col>
<a-col :span="5">
<a-card :bordered="false" class="table-container">
<a-row>
<ptz-control @playflv="playflv" @turnPosition="turnPosition"> </ptz-control>
</a-row>
</a-card>
</a-col>
<a-col :span="5">
<a-card :bordered="false" class="table-container">
<a-button type="primary" @click="addRow" icon="plus" :disabled="disabledAddBtn">添加预置点</a-button>
<table class="my-table">
<thead>
<tr>
<th>序号</th>
<th>预置点</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="(item, index) in tableData" :key="index" @dblclick="handleDoubleClick(item)">
<td>{{ index + 1 }}</td>
<td>{{ '预置点' + (index + 1) }}</td>
<td><a @click="delItem(item.id)">删除</a></td>
</tr>
</tbody>
</table>
</a-card>
</a-col>
</a-row>
</div>
</template>
<script>
import flvjs from "flv.js";
import PtzControl from './PtzControl.vue'
import { postAction, getAction } from '@/api/manage'
import { deleteAction } from '../../../api/manage'
export default {
components: {
PtzControl,
},
data() {
return {
rtspUrl:'rtsp://192.168.18.4:8554/video',
}
},
created() {
},
mounted() {
if (flvjs.isSupported()) {
let video = this.$refs.player
if (video) {
this.player = flvjs.createPlayer({
type: 'flv',
isLive: true,
hasAudio: false,
enableStashBuffer: false,
#刚才搭建的服务端地址加端口,只替换url即可;
url: `ws://192.168.18.5:8888/rtsp/0/?url=`+this.rtspUrl
})
this.player.attachMediaElement(video)
try {
this.player.load()
this.player.play()
//console.log("play");
} catch (error) {
//console.log(error);
this.$Notice.error({
title: '播放错误',
desc: error,
})
}
}
}
},
}
</script>
<style lang="less" scoped>
.video {
background-color: lightgray;
height: 480px;
width: 100%;
}
.table-container {
height: 550px; /* 设置表格容器的最大高度 */
overflow-y: auto; /* 启用垂直滚动条 */
}
.my-table {
margin-top: 30px;
width: 100%;
border-collapse: collapse;
}
.my-table th,
.my-table td {
padding: 10px;
border: 1px solid #ccc;
}
.my-table th {
background-color: #f0f0f0;
}
.my-table tbody tr:nth-child(even) {
background-color: #f9f9f9;
}
.my-table tbody tr:hover {
background-color: #eaeaea;
}
</style>
PTZ云台控件
<template>
<div>
<div class="pie">
<!-- 上 -->
<div class="slice-one slice" @click="turnPosition('FF01000800FF08','上')">
<div class="dv"><div class="sj"></div></div>
</div>
<!-- 右上 -->
<div class="slice-two slice" @click="turnPosition('FF01000A0F0F29','右上')">
<div class="dv"><div class="sj"></div></div>
</div>
<!-- 右 -->
<div class="slice-three slice" @click="turnPosition('FF010002FF0002','右')">
<div class="dv"><div class="sj"></div></div>
</div>
<!-- 右下 -->
<div class="slice-four slice" @click="turnPosition('FF0100120F0F31','右下')">
<div class="dv"><div class="sj"></div></div>
</div>
<!-- 下 -->
<div class="slice-five slice" @click="turnPosition('FF01001000FF10','下')">
<div class="dv"><div class="sj"></div></div>
</div>
<!-- 左下 -->
<div class="slice-six slice" @click="turnPosition('FF0100140F0F33','左下')">
<div class="dv"><div class="sj"></div></div>
</div>
<!-- 左 -->
<div class="slice-seven slice" @click="turnPosition('FF010004FF0004','左')">
<div class="dv"><div class="sj"></div></div>
</div>
<!-- 左上 -->
<div class="slice-eight slice" @click="turnPosition('FF01000C0F0F2B','左上')">
<div class="dv"><div class="sj"></div></div>
</div>
<div class="center" @click="reload">
<img src="../../../assets/reload.png" style="width: 50px; margin-left: 25%; margin-top: 25%;" />
</div>
</div>
<div class="progress-bar row">
<div style="width:150px">手动速度:</div>
<slid :min="0" :max="100" @input="changeSpeed" v-model="obj.speed" :isDrag="true" bgColor="#268cf2"></slid>
</div>
<div class="line row">
<div style="width:150px">变倍:
<a-button type="primary" @click="turnPosition('FF010020000021','')" shape="circle" style="font-size: 20px; margin-left: 20px">+</a-button>
</div>
<a-button type="primary" @click="turnPosition('FF010040000041','')" shape="circle" style="font-size: 20px; margin-left: 20px">-</a-button>
</div>
<div class="line row">
<div style="width:150px">聚焦:
<a-button type="primary" @click="turnPosition('FF010100000002','')" shape="circle" style="font-size: 20px; margin-left: 20px">+</a-button>
</div>
<a-button type="primary" @click="turnPosition('FF010080000081','')" shape="circle" style="font-size: 20px; margin-left: 20px">-</a-button>
</div>
<div class="line row">
<div style="width:150px">光圈:
<a-button type="primary" @click="turnPosition('FF010200000003','')" shape="circle" style="font-size: 20px; margin-left: 20px">+</a-button>
</div>
<a-button type="primary" @click="turnPosition('FF010400000005','')" shape="circle" style="font-size: 20px; margin-left: 20px">-</a-button>
<!-- <div style="width:150px">激光:
<a-button type="primary" @click="turnPosition('FF01120900001C')" shape="circle" style="font-size: 20px; margin-left: 20px">开</a-button>
</div>
<a-button type="primary" @click="turnPosition('FF01120A00001D')" shape="circle" style="font-size: 20px; margin-left: 20px">关</a-button> -->
</div>
</div>
</template>
<script>
import Slid from './Slid.vue'
export default {
components:{
Slid
},
data() {
return {
dragging: false,
progress: 0,
obj:{},
SpeedType:'',
}
},
methods: {
reload() {
this.$emit('playflv')
},
turnPosition(position,SpeedType) {
this.obj.position = position;
if(SpeedType) {
this.SpeedType = SpeedType;
if(this.obj.speed) {
var speedParam = {
Fun: 410,
Cmd: 3,
ControlType: 'SerialControl',
ID:1,
};
this.getHVSpeed(speedParam,this.obj.speed);
this.$emit('turnPosition',speedParam)
return;
}
}
this.$emit('turnPosition',this.obj)
},
changeSpeed(speed) {
this.obj.speed = speed;
},
getHVSpeed(speedParam,speed) {
var SpeedType = this.SpeedType
speedParam.SpeedType = SpeedType
// HSpeed为横向速度,VSpeed为纵向速度
if(SpeedType == '左上' || SpeedType == '左下' || SpeedType == '右上' || SpeedType == '右下') {
speedParam.HSpeed = speed
speedParam.VSpeed = speed
}
if(SpeedType == '上') {
speedParam.SpeedType = '右上'
speedParam.HSpeed = 0
speedParam.VSpeed = speed
} else if(SpeedType == '下') {
speedParam.SpeedType = '右下'
speedParam.HSpeed = 0
speedParam.VSpeed = speed
} else if(SpeedType == '左') {
speedParam.SpeedType = '左下'
speedParam.HSpeed = speed
speedParam.VSpeed = 0
} else if(SpeedType == '右') {
speedParam.SpeedType = '右下'
speedParam.HSpeed = speed
speedParam.VSpeed = 0
}
}
},
}
</script>
<style lang="less" scoped>
.pie {
transform: scale3d(1.22, 1.22, 1.22);
position: relative;
margin: 20px auto;
padding: 0;
width: 200px;
height: 200px;
border-radius: 50%;
list-style: none;
overflow: hidden;
// background: url('../../../assets/reload.png') no-repeat center center / 100% 100%;
.center {
position: absolute;
width: 100px;
height: 100px;
top: 50px;
left: 50px;
border-radius: 50%;
background-color: white;
&::after {
content: '';
position: absolute;
width: 50px;
height: 50px;
top: 25px;
left: 25px;
border-radius: 50%;
z-index: 9;
// background-color: #f1f5fd;
}
&:active {
background-color: #bbc9d6;
}
}
.slice {
background-color: #f1f5fd;
overflow: hidden;
position: absolute;
top: 0;
right: 0;
width: 50%;
height: 50%;
transform-origin: 0% 100%;
border-left: 3px solid white;
&:active {
background-color: #268cf2;
.dv .sj {
border-bottom: 10px solid white;
}
}
// border-bottom: 5px solid white;
.dv {
transform: skewY(45deg) rotate(22.5deg);
margin-top: 105px;
margin-left: 15px;
.sj {
position: fixed;
bottom: 0;
width: 0;
height: 0;
border-top: 10px solid transparent;
border-right: 10px solid transparent;
border-bottom: 10px solid #268cf2;
border-left: 10px solid transparent;
}
}
&.slice-one {
transform: rotate(-22.5deg) skewY(-45deg);
}
&.slice-two {
transform: rotate(22.5deg) skewY(-45deg);
}
&.slice-three {
transform: rotate(67.5deg) skewY(-45deg);
}
&.slice-four {
transform: rotate(112.5deg) skewY(-45deg);
}
&.slice-five {
transform: rotate(157.5deg) skewY(-45deg);
}
&.slice-six {
transform: rotate(202.5deg) skewY(-45deg);
}
&.slice-seven {
transform: rotate(247.5deg) skewY(-45deg);
}
&.slice-eight {
transform: rotate(292.5deg) skewY(-45deg);
}
}
}
.progress-bar {
margin-top: 50px;
}
.line {
margin-top: 20px;
}
.row {
display: flex;
align-items: center;
}
</style>
Slid进度条控件
<template>
<div class="slider" ref="slider" @click.stop="handelClickSlider">
<div class="process" :style="{ width,background:bgColor }"></div>
<div class="thunk" ref="trunk" :style="{ left }">
<div class="block" ref="dot"></div>
{{per/10}}
</div>
</div>
</template>
<script>
/*
* min 进度条最小值
* max 进度条最大值
* v-model 对当前值进行双向绑定实时显示拖拽进度
* */
export default {
props: {
// 最小值
min: {
type: Number,
default: 0,
},
// 最大值
max: {
type: Number,
default: 100,
},
// 当前值
value: {
type: Number,
default: 0,
},
// 进度条颜色
bgColor: {
type: String,
default: "#4ab157",
},
// 是否可拖拽
isDrag: {
type: Boolean,
default: true,
},
},
data() {
return {
slider: null, //滚动条DOM元素
thunk: null, //拖拽DOM元素
per: this.value, //当前值
};
},
mounted() {
this.slider = this.$refs.slider;
this.thunk = this.$refs.trunk;
var _this = this;
if (!this.isDrag) return;
this.thunk.onmousedown = function (e) {
var width = parseInt(_this.width);
var disX = e.clientX;
document.onmousemove = function (e) {
// value, left, width
// 当value变化的时候,会通过计算属性修改left,width
// 拖拽的时候获取的新width
var newWidth = e.clientX - disX + width;
// 计算百分比
var scale = newWidth / _this.slider.offsetWidth;
_this.per = Math.ceil((_this.max - _this.min) * scale + _this.min); //取整
// 限制值大小
_this.per = Math.max(_this.per, _this.min);
_this.per = Math.min(_this.per, _this.max);
_this.$emit("input", Math.ceil(_this.per/10));
};
document.onmouseup = function () {
//当拖拽停止发送事件
_this.$emit("stop", _this.per/10);
//清除拖拽事件
document.onmousemove = document.onmouseup = null;
};
};
},
methods: {
handelClickSlider(event) {
//禁止点击
if (!this.isDrag) return;
const dot = this.$refs.dot;
if (event.target == dot) return;
//获取元素的宽度l
let width = this.slider.offsetWidth;
//获取元素的左边距
let ev = event || window.event;
//获取当前点击位置的百分比
let scale = ((ev.offsetX / width) * 100).toFixed(2);
this.per = Math.ceil(scale);
this.$emit("input", Math.ceil(this.per/10));
},
},
computed: {
// 设置一个百分比,提供计算slider进度宽度和trunk的left值
// 对应公式为 当前值-最小值/最大值-最小值 = slider进度width / slider总width
// trunk left = slider进度width + trunk宽度/2
scale() {
return (this.per - this.min) / (this.max - this.min);
},
width() {
return this.slider ? this.slider.offsetWidth * this.scale + "px" : "0px";
},
left() {
return this.slider ? this.slider.offsetWidth * this.scale - this.thunk.offsetWidth / 2 +"px" : "0px";
},
},
};
</script>
<style scoped>
.box {
margin: 100px auto 0;
width: 80%;
}
.clear:after {
content: "";
display: block;
clear: both;
}
.slider {
position: relative;
margin: 20px 0;
width: 100%;
height: 10px;
top: 50%;
background: #747475;
border-radius: 5px;
cursor: pointer;
z-index: 99999;
}
.slider .process {
position: absolute;
left: 0;
top: 0;
width: 112px;
height: 10px;
border-radius: 5px;
background: #4ab157;
z-index: 111;
}
.slider .thunk {
position: absolute;
left: 100px;
top: -4px;
width: 10px;
height: 6px;
z-index: 122;
}
.slider .block {
width: 16px;
height: 16px;
border-radius: 50%;
background: #268cf2;
transition: 0.2s all;
}
.slider .block:hover {
transform: scale(1.1);
opacity: 0.6;
}
</style>
最后效果
附带云台按钮和预置点页面,仅供参考