最近项目中需要对接摄像头监控,海康摄像头为rtsp流格式
有一个软件VLC media player,可以在线进行rtsp或者rtmp流播放,可用来测试流地址是否可用
功能实现思路为后台通过fmpeg把rtsp流进行转码,然后通过ws方式进行一帧一帧推送。(还有一种时后台通过fmpeg转为hls格式,但是这样会在项目中生产一张张图片,所以我们没有考虑)
首先后台我先使用node进行了一个测试dme,网上找了一个测试的rtmp地址:
rtmp://liteavapp.qcloud.com/live/liteavdemoplayerstreamid //好像是个游戏直播
const { spawn } = require('child_process');
const WebSocket = require('ws');
const wss = new WebSocket.Server({ port: 9999 });
wss.on('connection', (ws) => {
console.log('WebSocket 客户端连接');
// 关键修改1:使用完整FFmpeg路径(避免环境变量问题)
const ffmpegPath = 'D:\\rj\\ffmpeg\\bin\\ffmpeg.exe'; // 自己电脑的ffmpeg地址,自行更换确保路径存在
// 关键修改2:简化参数(移除可能冲突的选项)
const args = [
'-i', 'rtmp://liteavapp.qcloud.com/live/liteavdemoplayerstreamid', //网上的游戏直播地址
'-f', 'mpegts',
'-codec:v', 'mpeg1video',
'-'
];
// // rtsp的配置,和rtp略有区别
// const args= [
// '-rtsp_transport', 'tcp', // 强制TCP传输
// '-timeout', '5000000', // 5秒超时
// '-re', // 按原始速率读取
// '-i', 'rtsp://wowzaec2demo.streamlock.net/vod/mp4:BigBuckBunny_115k.mp4',
// '-f', 'mpegts',
// '-codec:v', 'mpeg1video', // JSMpeg兼容编码
// '-b:v', '800k', // 视频比特率
// '-r', '25', // 帧率
// '-vf', 'scale=640:480', // 分辨率缩放
// '-preset', 'ultrafast', // 最快编码预设
// '-fflags', 'nobuffer', // 减少缓冲
// '-'
// ]
// 关键修改3:显式传递环境变量
const env = { ...process.env, PATH: process.env.PATH }; // 继承当前环境
const ffmpeg = spawn(ffmpegPath, args, {
env: env,
stdio: ['ignore', 'pipe', 'pipe'] // 忽略stdin,捕获stdout/stderr
});
// 打印FFmpeg日志(调试用)
ffmpeg.stderr.on('data', (data) => {
console.log('[FFmpeg]', data.toString());
});
// 转发数据到WebSocket
ffmpeg.stdout.on('data', (data) => {
if (ws.readyState === ws.OPEN) {
ws.send(data, { binary: true });
}
});
ws.on('close', () => {
ffmpeg.kill('SIGTERM');
});
});
代码写完后通过node运行
前端在vue中采用 @cycjimmy/jsmpeg-player 或者 vue-jsmpeg-player,关于这点,因为vue-jsmpeg-player必须要求vue 2.6.12 以上,版本地低的话有问题,我这边采用@cycjimmy/jsmpeg-player
1.使用@cycjimmy/jsmpeg-player
npm install @cycjimmy/jsmpeg-player
npm下载安装后新建一个播放直播流的vue组件,代码如下
<template>
<div class="video-player">
<!-- 视频画布容器 -->
<div class="video-container" ref="videoContainer">
<canvas ref="videoCanvas" class="video-canvas"></canvas>
<!-- 加载状态 -->
<div v-if="isLoading" class="loading-overlay">
<div class="spinner"></div>
<p class="loading-text">加载中...</p>
</div>
<!-- 错误提示 -->
<div v-if="hasError" class="error-overlay">
<p class="error-text">无法加载视频流,请检查连接</p>
<button @click="reloadPlayer" class="reload-btn">重新加载</button>
</div>
</div>
<!-- 控制栏 -->
<div class="controls-bar">
<div class="controls-group">
<!-- 播放/暂停按钮 -->
<button
class="control-btn"
@click="togglePlay"
:title="isPlaying ? '暂停' : '播放'"
>
<i class="fas" :class="isPlaying ? 'fa-pause' : 'fa-play'"></i>
</button>
<!-- 音量控制 -->
<div class="volume-control">
<button
class="control-btn volume-btn"
@click="toggleMute"
:title="isMuted ? '取消静音' : '静音'"
>
<i class="fas" :class="isMuted ? 'fa-volume-mute' : 'fa-volume-up'"></i>
</button>
<input
type="range"
min="0"
max="100"
v-model="volume"
@input="setVolume"
class="volume-slider"
:title="`音量: ${volume}%`"
>
</div>
</div>
<div class="controls-group">
<!-- 全屏按钮 -->
<button
class="control-btn"
@click="toggleFullscreen"
:title="isFullscreen ? '退出全屏' : '进入全屏'"
>
<i class="fas" :class="isFullscreen ? 'fa-compress' : 'fa-expand'"></i>
</button>
</div>
</div>
</div>
</template>
<script>
import JSMpeg from '@cycjimmy/jsmpeg-player';
export default {
name: 'VideoPlayer',
props: {
// 视频流地址
streamUrl: {
type: String,
required: true,
default: 'ws://localhost:9999' //node后台1的ws地址
},
// 封面图
poster: {
type: String,
default: ''
}
},
data() {
return {
player: null,
isPlaying: false,
volume: 80,
isMuted: false,
isFullscreen: false,
isLoading: true,
hasError: false
};
},
mounted() {
this.initPlayer();
this.addEventListeners();
},
beforeUnmount() {
this.destroyPlayer();
this.removeEventListeners();
},
methods: {
// 初始化播放器
initPlayer() {
this.isLoading = true;
this.hasError = false;
try {
this.player = new JSMpeg.Player(this.streamUrl, {
canvas: this.$refs.videoCanvas,
autoplay: false,
loop: false,
controls: false,
poster: this.poster,
decodeFirstFrame: true,
volume: this.volume / 100,
// 事件回调
onPlay: () => {
this.isPlaying = true;
this.isLoading = false;
},
onPause: () => {
this.isPlaying = false;
},
onEnded: () => {
this.isPlaying = false;
},
onError: () => {
this.hasError = true;
this.isLoading = false;
this.isPlaying = false;
}
});
} catch (error) {
console.error('播放器初始化失败:', error);
this.hasError = true;
this.isLoading = false;
}
},
// 销毁播放器
destroyPlayer() {
if (this.player) {
this.player.destroy();
this.player = null;
}
},
// 重新加载播放器
reloadPlayer() {
this.destroyPlayer();
this.initPlayer();
},
// 切换播放/暂停
togglePlay() {
if (!this.player) return;
if (this.isPlaying) {
this.player.pause();
} else {
// 处理浏览器自动播放限制
this.player.play().catch(err => {
console.warn('自动播放失败,需要用户交互:', err);
this.showPlayPrompt();
});
}
},
// 显示播放提示(用于自动播放限制)
showPlayPrompt() {
const container = this.$refs.videoContainer;
const prompt = document.createElement('div');
prompt.className = 'play-prompt';
prompt.innerHTML = '<i class="fas fa-play"></i><p>点击播放</p>';
container.appendChild(prompt);
prompt.addEventListener('click', () => {
this.player.play();
container.removeChild(prompt);
}, { once: true });
},
// 切换静音
toggleMute() {
if (!this.player) return;
this.isMuted = !this.isMuted;
this.player.volume = this.isMuted ? 0 : this.volume / 100;
},
// 设置音量
setVolume() {
if (!this.player) return;
this.isMuted = this.volume === 0;
this.player.volume = this.volume / 100;
},
// 切换全屏
toggleFullscreen() {
const container = this.$refs.videoContainer;
if (!document.fullscreenElement) {
container.requestFullscreen().catch(err => {
console.error(`全屏错误: ${err.message}`);
});
this.isFullscreen = true;
} else {
if (document.exitFullscreen) {
document.exitFullscreen();
this.isFullscreen = false;
}
}
},
// 监听全屏状态变化
handleFullscreenChange() {
this.isFullscreen = !!document.fullscreenElement;
},
// 添加事件监听器
addEventListeners() {
document.addEventListener('fullscreenchange', this.handleFullscreenChange);
},
// 移除事件监听器
removeEventListeners() {
document.removeEventListener('fullscreenchange', this.handleFullscreenChange);
}
}
};
</script>
<style scoped>
.video-player {
position: relative;
width: 100%;
max-width: 1200px;
margin: 0 auto;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.video-container {
position: relative;
width: 100%;
background-color: #000;
aspect-ratio: 16 / 9; /* 保持视频比例 */
}
.video-canvas {
width: 100%;
height: 100%;
object-fit: contain;
}
/* 加载状态 */
.loading-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
background-color: rgba(0, 0, 0, 0.5);
color: white;
z-index: 10;
}
.spinner {
width: 40px;
height: 40px;
border: 4px solid rgba(255, 255, 255, 0.3);
border-radius: 50%;
border-top: 4px solid white;
animation: spin 1s linear infinite;
margin-bottom: 16px;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.loading-text {
font-size: 16px;
font-weight: 500;
}
/* 错误提示 */
.error-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
background-color: rgba(0, 0, 0, 0.7);
color: white;
z-index: 10;
padding: 20px;
text-align: center;
}
.error-text {
font-size: 16px;
margin-bottom: 20px;
max-width: 400px;
}
.reload-btn {
background-color: #42b983;
color: white;
border: none;
padding: 8px 16px;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
transition: background-color 0.2s;
}
.reload-btn:hover {
background-color: #359e75;
}
/* 播放提示 */
::v-deep .play-prompt {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
background-color: rgba(0, 0, 0, 0.5);
color: white;
z-index: 9;
cursor: pointer;
}
::v-deep .play-prompt i {
font-size: 48px;
margin-bottom: 16px;
transition: transform 0.2s;
}
::v-deep .play-prompt:hover i {
transform: scale(1.1);
}
/* 控制栏 */
.controls-bar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 20px;
background-color: #1a1a1a;
color: white;
}
.controls-group {
display: flex;
align-items: center;
gap: 16px;
}
.control-btn {
background: none;
border: none;
color: white;
font-size: 18px;
cursor: pointer;
width: 36px;
height: 36px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
transition: background-color 0.2s;
}
.control-btn:hover {
background-color: rgba(255, 255, 255, 0.15);
}
/* 音量控制 */
.volume-control {
display: flex;
align-items: center;
gap: 8px;
}
.volume-slider {
width: 100px;
height: 4px;
-webkit-appearance: none;
background: rgba(255, 255, 255, 0.2);
border-radius: 2px;
outline: none;
}
.volume-slider::-webkit-slider-thumb {
-webkit-appearance: none;
width: 14px;
height: 14px;
border-radius: 50%;
background: white;
cursor: pointer;
transition: transform 0.1s;
}
.volume-slider::-webkit-slider-thumb:hover {
transform: scale(1.2);
}
/* 响应式调整 */
@media (max-width: 768px) {
.controls-bar {
padding: 8px 12px;
}
.controls-group {
gap: 8px;
}
.control-btn {
font-size: 16px;
width: 32px;
height: 32px;
}
.volume-slider {
width: 80px;
}
}
</style>
然后在父组件中引入使用就可以了。
2.使用 vue-jsmpeg-player
npm i vue-jsmpeg-player@1.1.0-beta
安装后在main.js全局引入:
import JSMpegPlayer from 'vue-jsmpeg-player';
import 'vue-jsmpeg-player/dist/jsmpeg-player.css';
Vue.use(JSMpegPlayer)
然后新建vue组件:
<template>
<div>
<jsmpeg-player :url="url" />
</div>
</template>
<script>
export default {
components: {},
data() {
return {
//后台转发的ws地址
url: "ws://10.10.10.113:9999", //后台ws地址
};
},
computed: {},
mounted() {
// jsmpeg-player组件的使用说明
// url string 视频流地址(推荐websocket,实际上jsmpeg.js原生也支持http方式,但没有经过测试)
// title string 播放器标题
// no-signal-text string 无信号时的显示文本
// options object jsmpeg原生选项,直接透传,详见下表
// closeable boolean 是否可关闭(单击关闭按钮,仅抛出事件)
// in-background boolean 是否处于后台,如el-tabs的切换,路由的切换等,支持.sync修饰符
// show-duration boolean 是否现实持续播放时间
// default-mute boolean 默认静音
// with-toolbar boolean 是否需要工具栏
// loading-text boolean 加载时的文本,默认为:拼命加载中
},
beforeDestroy() {},
methods: {},
};
</script>
<style lang="scss">
</style>