前言
哪一天我心血来潮,想把我儿子学校的摄像头视频流录制下来,并保存到云盘上,这样我就可以在有空的时候看看我儿子在学校干嘛。想到么就干,当时花了一些时间开发了一个后端服务,通过数据库配置录制参数,以后的设想是能够通过页面去配置,能够自动捕获直播视频流,这还得要求自己先学会vue,所以还得缓缓。
实现
技术栈:Spring Boot、Webflux、r2dbc、javacv
架构图:
流程很简单,主要还是要用到JavaCV从视频流里捕获视频,先报错到本地,然后有一个定时任务会定时去检测目录内是否有新生成的文件,有就上传到配置的云盘(百度云)。
1、创建pom
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.6.4</version>
<relativePath /> <!-- lookup parent from repository -->
</parent>
<groupId>net.178le</groupId>
<artifactId>video-cloud-record</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>video-cloud-record</name>
<description>视频云录制</description>
<properties>
<java.version>1.8</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-r2dbc</artifactId>
</dependency>
<dependency>
<groupId>dev.miku</groupId>
<artifactId>r2dbc-mysql</artifactId>
</dependency>
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.7.22</version>
</dependency>
<dependency>
<groupId>org.bytedeco</groupId>
<artifactId>javacv-platform</artifactId>
<version>1.4.4</version>
</dependency>
<dependency>
<groupId>org.apache.httpcomponents</groupId>
<artifactId>httpcore</artifactId>
<version>4.4.10</version>
</dependency>
<dependency>
<groupId>org.apache.httpcomponents</groupId>
<artifactId>httpclient</artifactId>
<version>4.5.6</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.projectreactor</groupId>
<artifactId>reactor-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<finalName>video-cloud-record</finalName>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
</plugins>
</build>
</project>
2、定时异常信息
package net.video.record.config;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import lombok.extern.slf4j.Slf4j;
/**
* @desc 全局异常捕捉并转换异常
*/
@Slf4j
@RestControllerAdvice(basePackages = "net.video.record")
public class GlobalExceptionHandler {
@ExceptionHandler(Exception.class)
public Result<String> handleException(Exception e) {
log.error("{}", e);
return Result.error("", e.getMessage());
}
}
3、统一结果集
package net.video.record.config;
import cn.hutool.core.util.StrUtil;
import lombok.AllArgsConstructor;
import lombok.Data;
@Data
@AllArgsConstructor
public class Result<T> {
private String code;
private T data;
private String msg;
public static <T> Result<T> ok(T data) {
return new Result<T>("0", data, "");
}
public static <T> Result<T> error(String code, String msg) {
code = StrUtil.isEmpty(code)? "500" : code;
return new Result<T>(code, null, msg);
}
}
4、定义两个Model
TaskList 用来保存用户相关的录制任务
package net.video.record.entity.model;
import java.time.LocalDateTime;
import java.util.Date;
import org.springframework.data.annotation.Id;
import org.springframework.data.relational.core.mapping.Table;
import lombok.Data;
@Data
@Table("task_list")
public class TaskList {
@Id
private Integer id;
private String name;
private String streamUrl;
private Integer userId;
private Integer status;
private Integer delFlag;
private LocalDateTime createTime;
private LocalDateTime modifyTime;
private String runRule;
private LocalDateTime lastRunTime;
private Integer recordTime;
private Integer segTime;
}
User 定义用户信息,保存了用过相关的录制参数
package net.video.record.entity.model;
import java.time.LocalDateTime;
import java.util.Date;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import org.springframework.data.annotation.Id;
import org.springframework.data.relational.core.mapping.Table;
import lombok.Data;
import lombok.experimental.Accessors;
@Data
@Accessors(chain = true)
@Table("user")
public class User {
public static Map<Integer, User> userMap = new ConcurrentHashMap<Integer, User>();
@Id
private Integer id;
private String userName;
private String password;
private String bdAccessToken;
private String bdRefreshToken;
private LocalDateTime createTime;
private LocalDateTime modifyTime;
}
5、几个VO
TaskReq 任务请求参数
package net.video.record.entity.vo;
import lombok.Data;
import lombok.experimental.Accessors;
@Data
@Accessors(chain = true)
public class TaskReq {
private Integer taskId;
}
UserReq
package net.video.record.entity.vo;
import lombok.Data;
@Data
public class UserReq {
private String userName;
private String password;
}
UserRes
package net.video.record.entity.vo;
import java.time.LocalDateTime;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
@Data
public class UserRes {
private Integer id;
private String userName;
private String password;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime createTime;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime modifyTime;
}
6、把网盘接口封装一下
我封装的是百度网盘,可以去网盘开放平台查看文档,这里贴出主要的上传代码。
public String upload(BdFileUpload req, TaskList task) {
User user = User.userMap.get(task.getUserId());
if (user == null) {
throw new RuntimeException("用户信息不存在");
}
//大于4m的话分片,这里先不处理分片
File file = req.getFile();
req.setAccess_token(user.getBdAccessToken());
List<String> fileMd5 = Arrays.asList(SecureUtil.md5(file));
PreCreateReq preCreateReq = new PreCreateReq().setAccess_token(req.getAccess_token())
.setAutoinit(1).setIsdir(0).setRtype(1)
.setPath("/apps/直播云存储/" + task.getId() + "/" + DateUtil.today() + "/" + file.getName())
.setSize(String.valueOf(file.length()))
.setBlock_list(JSONUtil.toJsonStr(fileMd5));
PreCreateRes preCreate = preCreate(preCreateReq);
for (int i = 0; i < fileMd5.size(); i++) {
SegUploadReq segUploadReq = new SegUploadReq()
.setAccess_token(req.getAccess_token())
.setPath(preCreate.getPath())
.setUploadid(preCreate.getUploadid())
.setPartseq(i)
.setFile(req.getFile());
SegUploadRes segUploadRes = SegUpload(segUploadReq);
}
CreateFileReq createFileReq = new CreateFileReq().setAccess_token(req.getAccess_token())
.setBlock_list(JSONUtil.toJsonStr(fileMd5))
.setPath(preCreateReq.getPath())
.setSize(preCreateReq.getSize())
.setIsdir(preCreateReq.getIsdir())
.setRtype(preCreateReq.getRtype())
.setUploadid(preCreate.getUploadid());
CreateFileRes createFile = createFile(createFileReq);
return createFile.getServer_filename();
}
7、视频流录制部分
/**
* 录制视频
* @param inputFile 该地址可以是网络直播/录播地址,也可以是远程/本地文件路径
* @param outputFile 该地址只能是文件地址,如果使用该方法推送流媒体服务器会报错,原因是没有设置编码格式
* @param audioChannel 是否录制音频 1录制
* @param time 录制时间
* @throws Exception
* @throws org.bytedeco.javacv.FrameRecorder.Exception
*/
public void frameRecord(String inputFile, String outputFile, int audioChannel, int time)
throws Exception, org.bytedeco.javacv.FrameRecorder.Exception {
// 获取视频源
FFmpegFrameGrabber grabber = new FFmpegFrameGrabber(inputFile);
// 流媒体输出地址,分辨率(长,高),是否录制音频(0:不录制/1:录制)
FFmpegFrameRecorder recorder = new FFmpegFrameRecorder(outputFile, 1280, 720, audioChannel);
recorder.setVideoCodec(avcodec.AV_CODEC_ID_H264);
recorder.setAudioCodec(avcodec.AV_CODEC_ID_AAC);
//设置分片
recorder.setFormat("segment");
//生成模式 实时
recorder.setOption("segment_list_flags", "live");
//分片时长 60s
recorder.setOption("segment_time", "60");
//锁定分片时长
recorder.setOption("segment_atclocktime", "1");
//用来严格控制分片时长
recorder.setOption("break_non_keyframes", "1");
//设置日志级别
avutil.av_log_set_level(avutil.AV_LOG_ERROR);
// 开始取视频源
try {
grabber.start();
recorder.start();
Frame frame = null;
Date startDate = new Date();
while ((frame = grabber.grabFrame()) != null
&& DateUtil.between(startDate, new Date(), DateUnit.SECOND) <= time * 60) {
recorder.record(frame);
}
recorder.stop();
grabber.stop();
} finally {
if (grabber != null) {
grabber.stop();
}
}
}
总结
这里我只贴出了部分代码,如果有想要了解具体实现的,也可以留言跟我交流。这个系统我也只是快速实现了一下,只达到能用的程度,其中对javacv、webflux进行了一定学习研究,后续的完善,还要看我哪天再次心血来潮。