前端采用vue3+字节开源的西瓜视频播放FLV视频,后端使用ffmpeg+nginx对海康摄像头提供的rtsp流进行推拉流。
1.Nginx配置
针对不同系统环境可以分别去下载编译nginx-rtmp-module或者nginx-http-flv-module
windows编译相对复杂需要装一大堆软件,可以用下面地址进行下载:
链接: https://pan.baidu.com/s/1ND7DI16X4x3PUPnlWCDfuA?pwd=6rt8 提取码: 6rt8
linux比较交单,百度搜一搜,找个教程,按照教程进行编译就行了
下面贴一下nginx.conf的配置:
worker_processes 1;
#error_log logs/error.log;
#error_log logs/error.log notice;
#error_log logs/error.log info;
#error_log logs/error.log debug;
#pid logs/nginx.pid;
events {
worker_connections 1024;
}
# 添加RTMP服务
rtmp {
server {
listen 1935; # 监听端口
chunk_size 4000;
application live {
live on;
gop_cache on;
}
}
}
# HTTP服务
http {
include mime.types;
default_type application/octet-stream;
#access_log logs/access.log main;
server {
listen 8090; # 监听端口
#http-flv的相关配置
location /flv {
flv_live on; #打开HTTP播放FLV直播流功能
chunked_transfer_encoding on;
add_header 'Access-Control-Allow-Origin' '*';
add_header 'Access-Control-Allow-Credentials' 'true';
}
location /stat.xsl {
root html;
}
location /stat {
rtmp_stat all;
rtmp_stat_stylesheet stat.xsl;
}
location / {
root html;
}
}
}
上面的配置只是针对flv格式播放,如果需要hls格式播放,需要增加hls的配置,配置如下:
worker_processes 1;
events {
worker_connections 1024;
}
rtmp {
out_queue 4096;
out_cork 8;
max_streams 128;
timeout 15s;
drop_idle_publisher 15s;
log_interval 5s; #interval used by log module to log in access.log, it is very useful for debug
log_size 1m; #buffer size used by log module to log in access.log
server {
listen 1935;
chunk_size 4096;
server_name localhost; #for suffix wildcard matching of virtual host
application live {
live on;
gop_cache on; #open GOP cache for reducing the wating time for the first picture of video
#live on;
hls on;
hls_path D:\video;
hls_fragment 1s;
hls_playlist_length 3s;
# hls_playlist_length 60s;
#hls_continuous on;
#hls_cleanup on;
#hls_nested on;
wait_key on;
#meta off;
#allow play all;
}
}
}
http {
include mime.types;
default_type application/octet-stream;
sendfile on;
keepalive_timeout 65;
server {
listen 88;
#charset utf-8;
#access_log logs/host.access.log main;
server_name localhost;
# 拉http-flv的配置
location /flv {
flv_live on;
chunked_transfer_encoding on;
add_header 'Access-Control-Allow-Origin' '*';
add_header 'Access-Control-Allow-Credentials' 'true';
}
#配置hls点播
location /hls {
types{
application/vnd.apple.mpegurl m3u8;
video/mp2t ts;
}
alias D:\video; # 读取文件的位置,应和上面rtmp中的配置一样
expires -1;
add_header 'Cache-Control' 'no-cache';
add_header 'Access-Control-Allow-Origin' '*' always;
add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range';
add_header 'Access-Control-Allow-Headers' 'Range';
#add_header Cache-Control no-cache;
#add_header 'Access-Control-Allow-Origin' '*';
#add_header 'Access-Control-Allow-Credentials' '*';
#add_header 'Access-Control-Allow-Methods' 'GET, HEAD, OPTIONS';
#add_header 'Access-Control-Expose-Headers' 'Server,range,Content-Length,Content-Range';
}
# 定义状态的访问URI
location /stat {
rtmp_stat all;
rtmp_stat_stylesheet stat.xsl;
}
# 定义状态文件路径
location /stat.xsl {
root html;
}
# 定义播放器网页访问的URI和根目录
location / {
root html;
}
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root html;
}
}
}
我是用了两个nginx进行的测试,所以有的端口写的不一样,这个端口不重复就行,配置好了直接启动可以使用nginx -t验证一下配置文件是否正确。没问题直接启动nginx。
注意:端口要和配置文件的一样
# windows 启动
start nginx
# linux 启动
./nginx
# 重启 windows/linux
nginx -s reload
# 停止 windows/linux
nginx -s stop
2.下载解压FFMPEG
下载地址:
https://github.com/BtbN/FFmpeg-Builds/releases
windows环境下载解压就行了,linux我用centos7出现了缺少包的情况,我升级了gcc和glibc解决了这个问题。
注意:glibc没有一定经验不要乱动,有系统崩溃的风险。
3.测试使用FFMPEG进行直播推流
ffmpeg.exe -rtsp_transport tcp -buffer_size 4096000
-i "rtsp://admin:hc@123456@192.168.1.70:554/Streaming/Channels/1?videoCodecType=H.264"
-vcodec copy -acodec copy -f flv "rtmp://127.0.0.1:1935/live/88"
上面的rtsp地址中的用户名、密码、IP、端口需要替换成自己的海康摄像头
然后使用VLC播放器播放。
播放地址:
注意: ip和port是否对应
rtmp: rtmp://127.0.0.1:1935/live/88
flv: http://127.0.0.1:88/flv?port=1935&app=live&stream=88
hls: http://127.0.0.1:88/hls/88.m3u8
4.SpringBoot整合FFMPEG
本方案采用的方式是使用外部FFMPEG程序,还可以使用javacv的方式,但是javacv方式较为复杂,我实现之后效果一般,遂采用的使用外部MMFPEG,好处是稳定性高,可以使用最新版的FFMPEG。下面贴一下关键代码。
/**
* @author cew
* @date 2023年7月20日17:26:29
*/
@Slf4j(topic = "admin-steam")
public class FfmpegUtil {
public static ConcurrentHashMap<String, Process> processMap = new ConcurrentHashMap<>();
private final static Integer LIVE_TIME = 15;
public static Process getProcess(String videoUrl){
String cacheKey = getCacheKey(videoUrl);
return processMap.get(cacheKey);
}
public static String getPlayUrl(String videoUrl){
String cacheKey = getCacheKey(videoUrl);
return SpringUtils.getBean(RedisCache.class).getCacheObject(cacheKey);
}
public static boolean push(String videoUrl, String flvUrl, Process process){
synchronized (FfmpegUtil.class){
String cacheKey = getCacheKey(videoUrl);
processMap.put(cacheKey,process);
SpringUtils.getBean(RedisCache.class).setCacheObject(cacheKey,flvUrl, LIVE_TIME, TimeUnit.MINUTES);
return true;
}
}
public static boolean keepLive(String videoUrl){
synchronized (FfmpegUtil.class){
String cacheKey = getCacheKey(videoUrl);
String cacheVideoUrl = SpringUtils.getBean(RedisCache.class).getCacheObject(cacheKey);
if (ObjectUtils.isEmpty(cacheVideoUrl)){
log.info("视频流保活失败:{}",videoUrl);
return false;
}
SpringUtils.getBean(RedisCache.class).setCacheObject(cacheKey,cacheVideoUrl,LIVE_TIME,TimeUnit.MINUTES);
log.info("视频流保活成功:{}",videoUrl);
return true;
}
}
public static boolean exists(String videoUrl){
String cacheKey = getCacheKey(videoUrl);
return SpringUtils.getBean(RedisCache.class).hasKey(cacheKey);
}
public static boolean destroy(String videoUrl){
synchronized (FfmpegUtil.class){
String cacheKey = getCacheKey(videoUrl);
Process process = processMap.get(cacheKey);
if (process != null){
process.destroy();
processMap.remove(cacheKey);
log.info("视频url:{}",videoUrl);
log.info("------------------------推流结束-------------------------");
}
SpringUtils.getBean(RedisCache.class).deleteObject(cacheKey);
return true;
}
}
private static String getCacheKey(String videoUrl)
{
return CacheConstants.CAMERA_KEY + videoUrl;
}
}
/**
* @author cew
*/
@Slf4j(topic = "admin-steam")
@Component
public class StreamGobbler {
/**
* 异步开启新的线程池
* @param is ffmpeg输入的错误流
* @param type 类型
* @param videoUrl 推流地址
*/
@Async("threadPoolTaskExecutor")
public void run(InputStream is, String type, String videoUrl) {
try {
InputStreamReader isr = new InputStreamReader(is);
BufferedReader br = new BufferedReader(isr);
String line = null;
while ((line = br.readLine()) != null) {
// 增加不续期取消推流
if (type.equals("Error")) {
if (FfmpegUtil.exists(videoUrl)){
log.info(line);
} else {
// 视频流不存在了跳出循环
log.info("缓存过期,跳出循环");
break;
}
} else {
log.debug(line);
}
}
// 无论正常还是意外导致跳出循环都应该注销视频流
FfmpegUtil.destroy(videoUrl);
} catch (IOException e) {
log.error("视频流出现异常:",e);
}
}
}
/**
* 转换视频流
* @author cew
*/
@Slf4j(topic = "admin-steam")
@Component
public class ConversionVideo {
@Autowired
private StreamGobbler errorGobbler;
/**
* 推流
* @param videoUrl 推流视频地址
* @param controllerUrl rtmp地址
* @param flvUrl 前端播放地址
* @return 播放地址
*/
public String pushVideoAsRTSP(String videoUrl, String controllerUrl,String flvUrl){
//ffmpeg.exe -rtsp_transport tcp -buffer_size 4096000 -i "rtsp://admin:hc@123456@192.168.1.70:554/Streaming/Channels/1?videoCodecType=H.264" -vcodec copy -acodec copy -f flv "rtmp://127.0.0.1:1935/live/88"
Process process = FfmpegUtil.getProcess(videoUrl);
// 已经在推送了不需要进行操作
if(!ObjectUtils.isEmpty(process)){
return FfmpegUtil.getPlayUrl(videoUrl);
}
destroy(videoUrl);
// 获取ffmpeg位置 根据操作系统读取配置
String ffmpegPath = VideoConfig.getFfmpegPath();
try {
// cmd命令拼接,注意命令中存在空格
StringBuilder command = new StringBuilder(ffmpegPath) ;
// ffmpeg开头
command .append(" -rtsp_transport tcp");
// 减少卡顿或者花屏现象,相当于增加或扩大了缓冲区,给予编码和发送足够的时间。
command .append(" -buffer_size 4096000");
// 指定要推送的视频
command .append(" -i ").append(videoUrl);
// fps 设置帧频 缺省25
command .append(" -r 30 ");
// 音频选项, 一般后面加copy表示拷贝
command .append(" -vcodec copy");
// 音频选项, 一般后面加copy表示拷贝
command .append(" -acodec copy");
// 指定推送服务器,-f:指定格式
command .append(" -f flv ").append(controllerUrl);
log.info("ffmpeg推流命令:{}", command);
// 运行cmd命令,获取其进程
process = Runtime.getRuntime().exec(command.toString());
//这里处理process的信息
errorGobbler.run(process.getErrorStream(), "Error",videoUrl);
log.info("开始推流:{}",controllerUrl);
FfmpegUtil.push(videoUrl,flvUrl,process);
}catch (Exception e){
log.error("推流出现异常:",e);
}
return flvUrl;
}
public boolean keepLive(String videoUrl){
return FfmpegUtil.keepLive(videoUrl);
}
public boolean destroy(String videoUrl){
return FfmpegUtil.destroy(videoUrl);
}
}
/**
* @author Administrator
*/
public class StreamUtil {
/**
* 获取rtsp地址
* rtsp://admin:hc@123456@10.10.10.200:554/Streaming/Channels/101?videoCodecType=H.264
* @param userName 海康用户名
* @param password 海康密码
* @param channel 海康通道
* @param ip 海康ip
* @param port 海康端口
* @return rtsp 地址
*/
public static String getRtspUrl(String userName,String password,int channel,String ip,int port){
StringBuilder sb = new StringBuilder();
sb.append("rtsp://");
sb.append(userName);
sb.append(":");
sb.append(password);
sb.append("@");
sb.append(ip);
sb.append(":");
sb.append(port);
sb.append("/Streaming/Channels/");
sb.append(channel);
sb.append("?videoCodecType=H.264");
return sb.toString();
}
/**
* 获取rtmp地址
* rtmp://10.10.10.233:1935/live/1
* @param ip nginx_flv服务 ip地址
* @param port nginx_flv服务 端口
* @param appName 流标识
* @return rtmp地址
*/
public static String getRtmpUrl(String ip,String port,Integer appName){
StringBuilder sb = new StringBuilder();
sb.append("rtmp://");
sb.append(ip);
sb.append(":");
sb.append(port);
sb.append("/live/");
sb.append(appName);
return sb.toString();
}
/**
* 获取前端播放地址 前端转发到nginx或其他代里服务器
* @param flvPort nginx_flv服务 端口
* @param appName 设备id 流标识
* @return 前端播放地址
*/
public static String flvUrl(String flvPort,Integer appName){
StringBuilder sb = new StringBuilder();
sb.append("/flv?port=");
sb.append(flvPort);
sb.append("&app=live&stream=");
sb.append(appName);
return sb.toString();
}
}
/**
* 摄像头推流 使用外部 ffmpeg
* @author cew
* @date 2023年7月20日09:35:26
*/
public interface IVideoConversionService {
/**
* 推流
* @param imageData 实体类参数
*/
public String live(ImageData imageData);
/**
* 保活
* @param imageData 实体类参数
*/
public boolean keepLive(ImageData imageData);
/**
* 销毁
* @param imageData 实体类参数
*/
public boolean destroy(ImageData imageData);
}
/**
* @author cew
* @date 2023年7月20日10:23:30
*/
@Slf4j(topic = "admin-steam")
@Service
public class IVideoConversionServiceImpl implements IVideoConversionService {
@Autowired
private ConversionVideo conversionVideo;
@Override
public String live(ImageData imageData) {
String rtspUrl = StreamUtil.getRtspUrl(imageData.getUsername(), imageData.getPassword(),imageData.getChannel(), imageData.getIp(), imageData.getPort());
String rtmpUrl = StreamUtil.getRtmpUrl(VideoConfig.getFlvIp(), VideoConfig.getFlvPort(),imageData.getDeviceId());
String flvUrl = StreamUtil.flvUrl(VideoConfig.getFlvPort(), imageData.getDeviceId());
return conversionVideo.pushVideoAsRTSP(rtspUrl, rtmpUrl, flvUrl);
}
@Override
public boolean keepLive(ImageData imageData) {
String rtspUrl = StreamUtil.getRtspUrl(imageData.getUsername(), imageData.getPassword(),imageData.getChannel(), imageData.getIp(), imageData.getPort());
return conversionVideo.keepLive(rtspUrl);
}
@Override
public boolean destroy(ImageData imageData) {
String rtspUrl = StreamUtil.getRtspUrl(imageData.getUsername(), imageData.getPassword(),imageData.getChannel(), imageData.getIp(), imageData.getPort());
return conversionVideo.destroy(rtspUrl);
}
}
@Data
public class ImageData {
/** 连接ip地址 */
private String ip;
/** 连接端口 */
private short port;
/** 连接用户名 */
private String username;
/** 连接密码 */
private String password;
/** 通道号 */
private Integer channel;
/** 设备标识 */
private Integer deviceId;
}
上面使用Redis是做了一个15分钟的视频流保活(前端正常播放会每十分钟发送一次保活的请求),节省服务器资源,当无人观看时会关闭推流线程。该操作非必要,可根据业务需求是否保留。
5.使用西瓜播放器+VUE3播放FLV视频
西瓜视频播放器官网:http://h5player.bytedance.com/
相关的教程和配置参数都可以在官网查看。
首先肯定是初始化西瓜视频组件:
# 西瓜视频播放器组件主体
npm install xgplayer
or
yarn add xgplayer
# 西瓜视频播放器FLV扩展插件
npm i -S xgplayer-flv
or
yarn add xgplayer-flv
自定义组件主要分为两个文件,一个是获取播放地址及保活的,另一个是创建视频播放器的具体代码如下:
camera/video.vue
创建video的代码deviceId的作用是同一个页面可以多次创建video,保障id唯一;
移除了部分内置插件,增加了点击全屏,异常3s重连,播放自动跳帧等功能。
<template>
<div :id="'mse'+ deviceId"></div>
</template>
<script setup name="CameraXgPlayer">
import Player, { Events } from 'xgplayer';
import 'xgplayer/dist/index.min.css';
import LivePreset from 'xgplayer/es/presets/live'
import FlvPlugin from 'xgplayer-flv'
let player;
let frameSkipInterval = ref<String>("");
const props = defineProps({
deviceId: {
type: Number,
default: null,
},
playUrl: String,
})
onMounted(()=>{
initPlayer()
})
onBeforeUnmount(()=>{
destroy()
})
watch(
() => props.playUrl,
(newVal, oldVal) => {
replay()
}
)
function initPlayer(){
player = new Player({
lang: 'zh',
id: 'mse'+props.deviceId,
url: props.playUrl,
height: '100%',
width: '100%',
videoFillMode: 'fill',
playsinline: true,
plugins: [FlvPlugin],
presets: [LivePreset],
controls: {
autoHide: false,
mode: 'flex',
},
autoplayMuted: true,
autoplay: true,
screenShot:true,
ignores:['start','play','playbackRate','cssfullscreen'],
})
player.on(Events.USER_ACTION, (data) => {
console.log(player.isFullscreen)
console.log(data)
if (!player.isFullscreen && data.action === 'switch_play_pause'){
// 播放器进入全屏状态
player.getFullscreen()
} else {
player.play()
}
})
player.on(Events.PAUSE, () => {
player.play()
})
// 监听全屏变化
player.on(Events.FULLSCREEN_CHANGE, (isFullscreen) => {
if (isFullscreen) {
// 全屏
player.play()
} else {
// 退出全屏
}
})
player.on(Events.ERROR, (error) => {
// 出现播放错误
setTimeout(()=>{
replay()
},3000)
})
}
function frameSkip(){
setInterval(()=> {
if (player.buffered && player.buffered.length) {
// 获取buffered与currentTime的差值
let diff = player.buffered.end(0) - player.currentTime;
// 如果差值大于等于5s 手动跳帧 这里可根据自身需求来定
if (diff >= 5) {
//手动跳帧
player.currentTime = player.buffered.end(0);
}
}
},3 * 1000)
}
function replay(){
destroy()
initPlayer()
}
function destroy(){
player.destroy()
player = null
frameSkipInterval && clearInterval(frameSkipInterval)
frameSkipInterval = null
}
</script>
<style scoped lang="scss">
</style>
camera/index.vue
主要作用就是获取播放地址及视频保活,两个方法就是两个get方法比较简单,后端Controller构建了一个ImageData(参考第二节)对象,然后就是调用service方法或者播放地址或者视频流保活。
<template>
<div v-if="playUrl && deviceId">
<video-play :style="{width: '100%', height: '100%'}" :play-url="playUrl" :device-id="deviceId"></video-play>
</div>
</template>
<script setup name="CameraIndex">
import { connect, keepLive } from '@/api/coal/stream';
import VideoPlay from "./video.vue"
const props = defineProps({
deviceId: {
type: Number,
default: null,
},
})
const { proxy } = getCurrentInstance();
const route = useRoute();
const playUrl = ref('')
let keepLiveInterval = ref<String>("");
onMounted(() => {
nextTick(() => {
getPlayUrl()
})
keepLiveInterval = setInterval(()=>{
keepLive(props.deviceId).then(resp=>{})
},10*60*1000)
})
function getPlayUrl() {
connect(props.deviceId).then(resp=>{
playUrl.value = resp.msg
})
}
function destroy(){
keepLiveInterval && clearInterval(keepLiveInterval)
keepLiveInterval = null
}
onActivated(()=>{
})
onDeactivated(()=>{
})
onBeforeUnmount(() => {
destroy()
})
</script>
使用方法大致如下,具体的获取设备id或者后端或者摄像头地址可以根据自己的业务需求设计:
<template>
<CameraPlayer :style="{width: '100%', height: '350px'}" :device-id="deviceId"/>
</template>
<script setup>
import CameraPlayer from '@/views/camera/index'
const deviceId = ref('1');
</script>
下面配置是使用代里服务器映射播放地址,前端如使用nginx部署后需要在location增加类似配置,该配置非必须,如不使用代里服务,直接后端返回播放地址全路径类似于:
http://127.0.0.1:88/flv?port=1935&app=live&stream=88
server: {
open: true,
host: true,
proxy: {
// https://cn.vitejs.dev/config/#server-proxy
'/flv': {
target: 'http://127.0.0.1:8090/flv',
ws: true,
changeOrigin: true,
rewrite: (p) => p.replace(/^\/flv/, '')
},
}
}
6.结语
大概的代码就这么多了,本身是报着学习分享的精神写的这篇文章,如果有问题及遗漏可是直接评论,有不懂的地方也可以直接留言,没有写太多理论,可能需要一定的基础才能看的懂。
顺便说一下这个方案可能不太适合大型项目,大型项目还得是流媒体服务器。该方案的优点就是不需要搭建流媒体服务器,使用简单,应付一些内网项目或者小型项目应该问题不大。