nginx+ffmpeg+springboot+vue+西瓜视频-播放海康摄像头

        前端采用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.结语

        大概的代码就这么多了,本身是报着学习分享的精神写的这篇文章,如果有问题及遗漏可是直接评论,有不懂的地方也可以直接留言,没有写太多理论,可能需要一定的基础才能看的懂。

        顺便说一下这个方案可能不太适合大型项目,大型项目还得是流媒体服务器。该方案的优点就是不需要搭建流媒体服务器,使用简单,应付一些内网项目或者小型项目应该问题不大。

评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值