This commit is contained in:
砂糖
2025-09-26 11:55:38 +08:00
commit 7f07552ecc
628 changed files with 67849 additions and 0 deletions

View File

@@ -0,0 +1,34 @@
package com.ruoyi.video.common;
import org.springframework.aop.interceptor.AsyncUncaughtExceptionHandler;
import org.springframework.aop.interceptor.SimpleAsyncUncaughtExceptionHandler;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.AsyncConfigurer;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import java.util.concurrent.Executor;
/**
* @Author: orange
* @CreateTime: 2025-01-16
*/
@Configuration
public class AsyncConfig implements AsyncConfigurer {
@Override
public Executor getAsyncExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(5);
executor.setMaxPoolSize(10);
executor.setQueueCapacity(25);
executor.setThreadNamePrefix("AsyncThread-");
executor.initialize();
return executor;
}
@Override
public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
return new SimpleAsyncUncaughtExceptionHandler();
}
}

View File

@@ -0,0 +1,114 @@
package com.ruoyi.video.common;
import java.util.AbstractMap;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
/**
* 存储短暂对象的缓存类实现Map接口内部有一个定时器用来清除过期30秒的对象。
* 为避免创建过多线程没有特殊要求请使用getDefault()方法来获取本类的实例。
* @Author: orange
* @CreateTime: 2025-01-16
*/
public class CacheMap<K, V> extends AbstractMap<K, V> {
private static final long DEFAULT_TIMEOUT = 30000;
private static CacheMap<Object, Object> defaultInstance;
public static synchronized final CacheMap<Object, Object> getDefault() {
if (defaultInstance == null) {
defaultInstance = new CacheMap<Object, Object>(DEFAULT_TIMEOUT);
}
return defaultInstance;
}
private class CacheEntry implements Entry<K, V> {
long time;
V value;
K key;
CacheEntry(K key, V value) {
super();
this.value = value;
this.key = key;
this.time = System.currentTimeMillis();
}
@Override
public K getKey() {
return key;
}
@Override
public V getValue() {
return value;
}
@Override
public V setValue(V value) {
return this.value = value;
}
}
private class ClearThread extends Thread {
ClearThread() {
setName("clear cache thread");
}
public void run() {
while (true) {
try {
long now = System.currentTimeMillis();
Object[] keys = map.keySet().toArray();
for (Object key : keys) {
CacheEntry entry = map.get(key);
if (now - entry.time >= cacheTimeout) {
synchronized (map) {
map.remove(key);
}
}
}
Thread.sleep(cacheTimeout);
} catch (Exception e) {
e.printStackTrace();
}
}
}
}
private long cacheTimeout;
private Map<K, CacheEntry> map = new HashMap<K, CacheEntry>();
public CacheMap(long timeout) {
this.cacheTimeout = timeout;
new ClearThread().start();
}
@Override
public Set<Entry<K, V>> entrySet() {
Set<Entry<K, V>> entrySet = new HashSet<Map.Entry<K, V>>();
Set<Entry<K, CacheEntry>> wrapEntrySet = map.entrySet();
for (Entry<K, CacheEntry> entry : wrapEntrySet) {
entrySet.add(entry.getValue());
}
return entrySet;
}
@Override
public V get(Object key) {
CacheEntry entry = map.get(key);
return entry == null ? null : entry.value;
}
@Override
public V put(K key, V value) {
CacheEntry entry = new CacheEntry(key, value);
synchronized (map) {
map.put(key, entry);
}
return value;
}
}

View File

@@ -0,0 +1,37 @@
package com.ruoyi.video.common;
/**
* 定义一个枚举类,表示客户端类型
* @Author: orange
* @CreateTime: 2025-01-16
*/
public enum ClientType {
// 定义一个HTTP类型的客户端类型为0信息为"http"
HTTP(0,"http"),
// 定义一个WEBSOCKET类型的客户端类型为1信息为"websocket"
WEBSOCKET(1,"websocket"),
;
// 定义客户端类型
private int type;
// 定义客户端信息
private String info;
// 构造方法,初始化客户端类型和信息
private ClientType(int type, String info){
this.type = type;
this.info = info;
}
// 获取客户端类型
public int getType(){
return type;
}
// 获取客户端信息
public String getInfo(){
return info;
}
}

View File

@@ -0,0 +1,31 @@
package com.ruoyi.video.common;
import java.util.concurrent.LinkedBlockingDeque;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
/**
* 常量类
* @Author: orange
* @CreateTime: 2025-01-16
*/
public class MediaConstant {
// 服务器名称
public static String serverName = "EasyMedia";
// 线程池
public static ThreadPoolExecutor threadpool;
// ffmpeg路径键
public static String ffmpegPathKey;
// 构造方法
public MediaConstant() {
}
// 静态代码块初始化线程池和ffmpeg路径键
static {
// 创建线程池核心线程数为20最大线程数为500线程空闲时间60秒任务队列使用LinkedBlockingDeque拒绝策略为CallerRunsPolicy
threadpool = new ThreadPoolExecutor(20, 500, 60L, TimeUnit.SECONDS, new LinkedBlockingDeque(), new ThreadPoolExecutor.CallerRunsPolicy());
// ffmpeg路径键
ffmpegPathKey = "EasyMediaFFmpeg";
}
}

View File

@@ -0,0 +1,137 @@
package com.ruoyi.video.domain;
import com.ruoyi.common.annotation.Excel;
import com.ruoyi.common.core.domain.BaseEntity;
/**
* 设备对象 v_device
*
* @Author: orange
* @CreateTime: 2025-01-16
*/
public class Device extends BaseEntity {
private static final long serialVersionUID = 1L;
private Long deviceId;
@Excel(name = "IP地址")
private String ip;
@Excel(
name = "设备类型(1=haikan,2=dahua)"
)
private String type;
@Excel(
name = "区分流"
)
private String mediaKey;
@Excel(
name = "设备账号"
)
private String userName;
@Excel(
name = "设备密码"
)
private String password;
@Excel(
name = "播放地址"
)
private String url;
@Excel(
name = "启用flv(0=true,1=false)"
)
private String enabledFlv;
@Excel(
name = "启用hls(0=true,1=false)"
)
private String enabledHls;
@Excel(
name = "javacv/ffmpeg(默认不开启)"
)
private String mode;
public Device() {
}
public String toString() {
return "Device{deviceId=" + this.deviceId + ", ip='" + this.ip + "', type='" + this.type + "', mediaKey='" + this.mediaKey + "', userName='" + this.userName + "', password='" + this.password + "', url='" + this.url + "', enabledFlv='" + this.enabledFlv + "', enabledHls='" + this.enabledHls + "', mode='" + this.mode + "'}";
}
public Long getDeviceId() {
return this.deviceId;
}
public void setDeviceId(Long deviceId) {
this.deviceId = deviceId;
}
public String getIp() {
return this.ip;
}
public void setIp(String ip) {
this.ip = ip;
}
public String getType() {
return this.type;
}
public void setType(String type) {
this.type = type;
}
public String getMediaKey() {
return this.mediaKey;
}
public void setMediaKey(String mediaKey) {
this.mediaKey = mediaKey;
}
public String getUserName() {
return this.userName;
}
public void setUserName(String userName) {
this.userName = userName;
}
public String getPassword() {
return this.password;
}
public void setPassword(String password) {
this.password = password;
}
public String getUrl() {
return this.url;
}
public void setUrl(String url) {
this.url = url;
}
public String getEnabledFlv() {
return this.enabledFlv;
}
public void setEnabledFlv(String enabledFlv) {
this.enabledFlv = enabledFlv;
}
public String getEnabledHls() {
return this.enabledHls;
}
public void setEnabledHls(String enabledHls) {
this.enabledHls = enabledHls;
}
public String getMode() {
return this.mode;
}
public void setMode(String mode) {
this.mode = mode;
}
}

View File

@@ -0,0 +1,71 @@
package com.ruoyi.video.domain;
import com.ruoyi.common.annotation.Excel;
import com.ruoyi.common.core.domain.BaseEntity;
import org.apache.commons.lang3.builder.ToStringBuilder;
import org.apache.commons.lang3.builder.ToStringStyle;
/**
* 图片存储对象 v_image
*
* @author orange
* @date 2025-01-16
*/
public class VImage extends BaseEntity {
private static final long serialVersionUID = 1L;
/**
* 主键
*/
private Long id;
/**
* 图片名称
*/
@Excel(name = "图片名称")
private String imageName;
/**
* 图片数据
*/
@Excel(name = "图片数据")
private String imageData;
public void setId(Long id) {
this.id = id;
}
public Long getId() {
return id;
}
public void setImageName(String imageName) {
this.imageName = imageName;
}
public String getImageName() {
return imageName;
}
public void setImageData(String imageData) {
this.imageData = imageData;
}
public String getImageData() {
return imageData;
}
@Override
public String toString() {
return new ToStringBuilder(this, ToStringStyle.MULTI_LINE_STYLE)
.append("id", getId())
.append("imageName", getImageName())
.append("imageData", getImageData())
.append("createBy", getCreateBy())
.append("createTime", getCreateTime())
.append("updateBy", getUpdateBy())
.append("updateTime", getUpdateTime())
.append("remark", getRemark())
.toString();
}
}

View File

@@ -0,0 +1,76 @@
package com.ruoyi.video.domain.dto;
import java.io.Serializable;
import lombok.Getter;
import lombok.Setter;
/**
* camera相机
* @Author: orange
* @CreateTime: 2025-01-16
*/
@Getter
@Setter
public class CameraDto implements Serializable {
/**
*
*/
private static final long serialVersionUID = -5575352151805386129L;
/**
* rtsp、rtmp、d:/flv/test.mp4、desktop
*/
private String url;
/**
* 流备注
*/
private String remark;
/**
* flv开启状态
*/
private boolean enabledFlv = true;
/**
* hls开启状态
*/
private boolean enabledHls = false;
/**
* 是否启用ffmpeg启用ffmpeg则不用javacv
*/
private boolean enabledFFmpeg = false;
/**
* 无人拉流观看是否自动关闭流
*/
private boolean autoClose;
/**
* md5 key媒体标识区分不同媒体
*/
private String mediaKey;
/**
* 网络超时 ffmpeg默认5秒这里设置15秒
*/
private String netTimeout = "15000000";
/**
* 读写超时默认5秒
*/
private String readOrWriteTimeout = "15000000";
/**
* 无人拉流观看持续多久自动关闭默认1分钟
*/
private long noClientsDuration = 60000;
/**
* 0网络流1本地视频
*/
private int type = 0;
}

View File

@@ -0,0 +1,40 @@
package com.ruoyi.video.domain.vo;
import lombok.Data;
/**
* CameraVo
* @Author: orange
* @CreateTime: 2025-01-16
*/
@Data
public class CameraVo {
private String id;
/**
* 播放地址
*/
private String url;
/**
* 备注
*/
private String remark;
/**
* 启用flv
*/
private boolean enabledFlv = false;
/**
* 启用hls
*/
private boolean enabledHls = false;
/**
* javacv/ffmpeg
*/
private String mode = "未开启";
}

View File

@@ -0,0 +1,134 @@
package com.ruoyi.video.init;
import cn.hutool.core.util.StrUtil;
import com.ruoyi.video.common.MediaConstant;
import com.ruoyi.video.domain.Device;
import com.ruoyi.video.domain.dto.CameraDto;
import com.ruoyi.video.mapper.DeviceMapper;
import com.ruoyi.video.server.MediaServer;
import com.ruoyi.video.service.HlsService;
import com.ruoyi.video.service.MediaService;
import jakarta.annotation.PostConstruct;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.util.Iterator;
import java.util.List;
import org.bytedeco.ffmpeg.ffmpeg;
import org.bytedeco.javacpp.Loader;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.CommandLineRunner;
import org.springframework.core.env.Environment;
import org.springframework.stereotype.Component;
/**
* @Author: orange
* @CreateTime: 2025-01-16
*/
@Component
public class InitServer implements CommandLineRunner {
// 定义日志对象
private static final Logger log = LoggerFactory.getLogger(InitServer.class);
// 注入mediasServer.port的值
@Value("${mediasServer.port}")
private int port;
// 注入MediaServer对象
@Autowired
private MediaServer mediaServer;
// 注入MediaService对象
@Autowired
private MediaService mediaService;
// 注入HlsService对象
@Autowired
private HlsService hlsService;
// 注入DeviceMapper对象
@Autowired
private DeviceMapper deviceMapper;
// 注入Environment对象
@Autowired
private Environment env;
public InitServer() {
}
// 实现CommandLineRunner接口的run方法
public void run(String... args) throws Exception {
// 初始化自动播放
this.initAutoPlay();
// 获取本机IP地址
String ip = InetAddress.getLocalHost().getHostAddress();
// 获取http端口号
String httpPort = this.env.getProperty("server.port");
// 获取web路径
String path = this.env.getProperty("server.servlet.context-path");
if (StrUtil.isEmpty(path)) {
path = "";
}
// 打印启动信息
log.info("\n--------------------------------------------------------- \n" +
"\t RTSP视频分析平台启动成功! Access address: \n" +
"\t media port at : \t {} \n" +
"\t http port at : \t {} \n" +
"\t web Local: \t http://localhost:{} \n" +
"\t web External: \t http://{}:{}{} \n" +
"\t httpflv: \t http://{}:{}/live?url={您的源地址} \n" +
"\t wsflv: \t ws://{}:{}/live?url={您的源地址} \n" +
"\t hls(m3u8): \t http://{}:{}/hls?url={您的源地址} \n" +
"\t h2-database: \t http://{}:{}/h2-console \n" +
"\t 作者微信: \t chenbai0511 \n" +
"\t 请我喝杯咖啡吧qwq \n" +
"--------------------------------------------------------- \n",
port,
httpPort,
httpPort,
ip, httpPort, path,
ip, port,
ip, port,
ip, httpPort,
ip, httpPort);
// 启动MediaServer
this.mediaServer.start(new InetSocketAddress("0.0.0.0", this.port));
}
// 初始化自动播放
public void initAutoPlay() {
// 获取设备列表
List<Device> devices = this.deviceMapper.selectDeviceList(new Device());
if (null != devices && !devices.isEmpty()) {
log.info("已启动自动拉流!");
Iterator var2 = devices.iterator();
while (var2.hasNext()) {
Device device = (Device) var2.next();
CameraDto cameraDto = new CameraDto();
cameraDto.setUrl(device.getUrl());
cameraDto.setAutoClose(false);
cameraDto.setEnabledFFmpeg(device.getEnabledFlv().equals("0"));
cameraDto.setEnabledFlv(device.getEnabledFlv().equals("0"));
cameraDto.setEnabledHls(device.getEnabledHls().equals("0"));
cameraDto.setMediaKey(device.getMediaKey());
if (device.getEnabledFlv().equals("0")) {
this.mediaService.playForApi(cameraDto);
}
if (device.getEnabledHls().equals("0")) {
this.hlsService.startConvertToHls(cameraDto);
}
}
}
}
// 在初始化后加载FFmpeg
@PostConstruct
public void loadFFmpeg() {
String ffmpeg = Loader.load(org.bytedeco.ffmpeg.ffmpeg.class);
System.setProperty(MediaConstant.ffmpegPathKey, ffmpeg);
log.info("初始化资源成功");
}
}

View File

@@ -0,0 +1,61 @@
package com.ruoyi.video.mapper;
import com.ruoyi.video.domain.Device;
import java.util.List;
/**
* 设备Mapper接口
*
* @author orange
* @date 2025-01-14
*/
public interface DeviceMapper {
/**
* 查询设备
*
* @param deviceId 设备主键
* @return 设备
*/
public Device selectDeviceByDeviceId(Long deviceId);
/**
* 查询设备列表
*
* @param device 设备
* @return 设备集合
*/
public List<Device> selectDeviceList(Device device);
/**
* 新增设备
*
* @param device 设备
* @return 结果
*/
public int insertDevice(Device device);
/**
* 修改设备
*
* @param device 设备
* @return 结果
*/
public int updateDevice(Device device);
/**
* 删除设备
*
* @param deviceId 设备主键
* @return 结果
*/
public int deleteDeviceByDeviceId(Long deviceId);
/**
* 批量删除设备
*
* @param deviceIds 需要删除的数据主键集合
* @return 结果
*/
public int deleteDeviceByDeviceIds(Long[] deviceIds);
}

View File

@@ -0,0 +1,62 @@
package com.ruoyi.video.mapper;
import com.ruoyi.video.domain.VImage;
import java.util.List;
/**
* 图片存储Mapper接口
*
* @author orange
* @date 2025-01-16
*/
public interface VImageMapper
{
/**
* 查询图片存储
*
* @param id 图片存储主键
* @return 图片存储
*/
public VImage selectVImageById(Long id);
/**
* 查询图片存储列表
*
* @param vImage 图片存储
* @return 图片存储集合
*/
public List<VImage> selectVImageList(VImage vImage);
/**
* 新增图片存储
*
* @param vImage 图片存储
* @return 结果
*/
public int insertVImage(VImage vImage);
/**
* 修改图片存储
*
* @param vImage 图片存储
* @return 结果
*/
public int updateVImage(VImage vImage);
/**
* 删除图片存储
*
* @param id 图片存储主键
* @return 结果
*/
public int deleteVImageById(Long id);
/**
* 批量删除图片存储
*
* @param ids 需要删除的数据主键集合
* @return 结果
*/
public int deleteVImageByIds(Long[] ids);
}

View File

@@ -0,0 +1,281 @@
package com.ruoyi.video.server;
import cn.hutool.core.convert.Convert;
import cn.hutool.core.util.StrUtil;
import cn.hutool.crypto.digest.MD5;
import com.ruoyi.video.common.MediaConstant;
import com.ruoyi.video.domain.dto.CameraDto;
import com.ruoyi.video.service.MediaService;
import io.netty.buffer.Unpooled;
import io.netty.channel.ChannelFutureListener;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.DefaultChannelPromise;
import io.netty.channel.SimpleChannelInboundHandler;
import io.netty.channel.ChannelHandler.Sharable;
import io.netty.handler.codec.http.DefaultFullHttpResponse;
import io.netty.handler.codec.http.DefaultHttpResponse;
import io.netty.handler.codec.http.FullHttpRequest;
import io.netty.handler.codec.http.FullHttpResponse;
import io.netty.handler.codec.http.HttpHeaderNames;
import io.netty.handler.codec.http.HttpHeaderValues;
import io.netty.handler.codec.http.HttpResponse;
import io.netty.handler.codec.http.HttpResponseStatus;
import io.netty.handler.codec.http.HttpVersion;
import io.netty.handler.codec.http.QueryStringDecoder;
import io.netty.handler.codec.http.websocketx.BinaryWebSocketFrame;
import io.netty.handler.codec.http.websocketx.CloseWebSocketFrame;
import io.netty.handler.codec.http.websocketx.PingWebSocketFrame;
import io.netty.handler.codec.http.websocketx.PongWebSocketFrame;
import io.netty.handler.codec.http.websocketx.TextWebSocketFrame;
import io.netty.handler.codec.http.websocketx.WebSocketFrame;
import io.netty.handler.codec.http.websocketx.WebSocketServerHandshaker;
import io.netty.handler.codec.http.websocketx.WebSocketServerHandshakerFactory;
import io.netty.util.CharsetUtil;
import lombok.extern.slf4j.Slf4j;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
/**
* @Author: orange
* @CreateTime: 2025-01-16
*/
@Slf4j
@Service
@Sharable
public class FlvHandler extends SimpleChannelInboundHandler<Object> {
@Autowired
private MediaService mediaService;
private WebSocketServerHandshaker handshaker;
/**
* 网络超时
*/
@Value("${mediaserver.netTimeout:15000000}")
private String netTimeout;
/**
* 读写超时
*/
@Value("${mediaserver.readOrWriteTimeout:15000000}")
private String readOrWriteTimeout;
/**
* 无人拉流观看是否自动关闭流
*/
@Value("${mediaserver.autoClose:true}")
private boolean autoClose;
/**
* 无人拉流观看持续多久自动关闭1分钟
*/
@Value("${mediaserver.autoClose.noClientsDuration:60000}")
private long noClientsDuration;
@Override
protected void channelRead0(ChannelHandlerContext ctx, Object msg) throws Exception {
if (msg instanceof FullHttpRequest) {
FullHttpRequest req = (FullHttpRequest) msg;
// Map<String, String> parmMap = new RequestParser(msg).parse();
QueryStringDecoder decoder = new QueryStringDecoder(req.uri());
// 判断请求uri
if (!"/live".equals(decoder.path())) {
log.info("uri有误");
sendError(ctx, HttpResponseStatus.BAD_REQUEST);
return;
}
CameraDto cameraDto = buildCamera(req.uri());
if (StrUtil.isBlank(cameraDto.getUrl())) {
log.info("url有误");
sendError(ctx, HttpResponseStatus.BAD_REQUEST);
return;
}
if (!req.decoderResult().isSuccess() || (!"websocket".equals(req.headers().get("Upgrade")))) {
// http请求
sendFlvReqHeader(ctx);
mediaService.playForHttp(cameraDto, ctx);
} else {
// websocket握手请求升级
// 参数分别是ws地址子协议是否扩展最大frame长度
WebSocketServerHandshakerFactory factory = new WebSocketServerHandshakerFactory(
getWebSocketLocation(req), null, true, 5 * 1024 * 1024);
handshaker = factory.newHandshaker(req);
if (handshaker == null) {
WebSocketServerHandshakerFactory.sendUnsupportedVersionResponse(ctx.channel());
} else {
HttpResponse rsp = new DefaultHttpResponse(HttpVersion.HTTP_1_1, HttpResponseStatus.OK);
rsp.headers().set(HttpHeaderNames.SERVER, MediaConstant.serverName);
DefaultChannelPromise channelPromise = new DefaultChannelPromise(ctx.channel());
handshaker.handshake(ctx.channel(), req, rsp.headers(), channelPromise);
mediaService.playForWs(cameraDto, ctx);
}
}
} else if (msg instanceof WebSocketFrame) {
handleWebSocketRequest(ctx, (WebSocketFrame) msg);
}
}
@Override
public void channelActive(ChannelHandlerContext ctx) throws Exception {
// 添加连接
}
@Override
public void channelInactive(ChannelHandlerContext ctx) throws Exception {
// 断开连接
}
/**
* ws握手地址
*/
private String getWebSocketLocation(FullHttpRequest request) {
String location = request.headers().get(HttpHeaderNames.HOST) + request.uri();
return "ws://" + location;
}
/**
* 发送req header告知浏览器是flv格式
*
* @param ctx
*/
private void sendFlvReqHeader(ChannelHandlerContext ctx) {
HttpResponse rsp = new DefaultHttpResponse(HttpVersion.HTTP_1_1, HttpResponseStatus.OK);
rsp.headers().set(HttpHeaderNames.CONNECTION, HttpHeaderValues.CLOSE)
.set(HttpHeaderNames.CONTENT_TYPE, "video/x-flv").set(HttpHeaderNames.ACCEPT_RANGES, "bytes")
.set(HttpHeaderNames.PRAGMA, "no-cache").set(HttpHeaderNames.CACHE_CONTROL, "no-cache")
.set(HttpHeaderNames.TRANSFER_ENCODING, HttpHeaderValues.CHUNKED).set(HttpHeaderNames.SERVER, MediaConstant.serverName);
ctx.writeAndFlush(rsp);
}
/**
* 错误请求响应
*
* @param ctx
* @param status
*/
private void sendError(ChannelHandlerContext ctx, HttpResponseStatus status) {
FullHttpResponse response = new DefaultFullHttpResponse(HttpVersion.HTTP_1_1, status,
Unpooled.copiedBuffer("请求地址有误: " + status + "\r\n", CharsetUtil.UTF_8));
response.headers().set(HttpHeaderNames.CONTENT_TYPE, "text/plain; charset=UTF-8");
ctx.writeAndFlush(response).addListener(ChannelFutureListener.CLOSE);
}
/**
* websocket处理
*
* @param ctx
* @param frame
*/
private void handleWebSocketRequest(ChannelHandlerContext ctx, WebSocketFrame frame) {
// 关闭
if (frame instanceof CloseWebSocketFrame) {
handshaker.close(ctx.channel(), (CloseWebSocketFrame) frame.retain());
return;
}
// 握手PING/PONG
if (frame instanceof PingWebSocketFrame) {
ctx.write(new PongWebSocketFrame(frame.content().retain()));
return;
}
// 文本
if (frame instanceof TextWebSocketFrame) {
return;
}
if (frame instanceof BinaryWebSocketFrame) {
return;
}
}
/**
* 解析参数构建camera&&&参数必须加在url参数值后面&&&autoClose=false&&&hls=true
* ws://localhost:8866/live?url=rtsp://admin:VZCDOY@192.168.2.84:554/Streaming/Channels/102&&&autoClose=false
* @param url
* @return
*/
private CameraDto buildCamera(String url) {
CameraDto cameraDto = new CameraDto();
setConfig(cameraDto);
String[] split = url.split("url=");
String urlParent = split[1];
String[] split2 = urlParent.split("&&&");
if (split2.length > 0) {
for (String string : split2) {
if (string.indexOf("autoClose=") != -1) {
String[] as = string.split("=");
if (as.length <= 1) {
throw new RuntimeException("autoClose参数有误");
}
cameraDto.setAutoClose(Convert.toBool(as[1], false));
} else if (string.indexOf("ffmpeg=") != -1) {
String[] as = string.split("=");
if (as.length <= 1) {
throw new RuntimeException("ffmpeg参数有误");
}
cameraDto.setEnabledFFmpeg(Convert.toBool(as[1], false));
} else if (string.indexOf("hls=") != -1) {
String[] as = string.split("=");
if (as.length <= 1) {
throw new RuntimeException("hls参数有误");
}
cameraDto.setEnabledHls(Convert.toBool(as[1], false));
} else {
cameraDto.setUrl(string);
}
}
}
if (isLocalFile(cameraDto.getUrl())) {
cameraDto.setType(1);
}
// 区分不同媒体
String mediaKey = MD5.create().digestHex(cameraDto.getUrl());
cameraDto.setMediaKey(mediaKey);
return cameraDto;
}
/**
* 配置默认参数
*/
private void setConfig(CameraDto cameraDto) {
cameraDto.setNetTimeout(netTimeout);
cameraDto.setReadOrWriteTimeout(readOrWriteTimeout);
cameraDto.setAutoClose(autoClose);
cameraDto.setNoClientsDuration(noClientsDuration);
}
/**
* 是否是本地文件,判断前面长度是不是小于1个字符认为是盘符
* @return
*/
private boolean isLocalFile(String streamUrl) {
String[] split = streamUrl.trim().split("\\:");
if (split.length > 0) {
if (split[0].length() <= 1) {
return true;
}
}
return false;
}
}

View File

@@ -0,0 +1,89 @@
package com.ruoyi.video.server;
import io.netty.bootstrap.ServerBootstrap;
import io.netty.channel.ChannelFuture;
import io.netty.channel.ChannelHandler;
import io.netty.channel.ChannelInitializer;
import io.netty.channel.ChannelOption;
import io.netty.channel.EventLoopGroup;
import io.netty.channel.WriteBufferWaterMark;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.SocketChannel;
import io.netty.channel.socket.nio.NioServerSocketChannel;
import io.netty.channel.unix.PreferredDirectByteBufAllocator;
import io.netty.handler.codec.http.HttpObjectAggregator;
import io.netty.handler.codec.http.HttpRequestDecoder;
import io.netty.handler.codec.http.HttpResponseEncoder;
import io.netty.handler.codec.http.cors.CorsConfig;
import io.netty.handler.codec.http.cors.CorsConfigBuilder;
import io.netty.handler.codec.http.cors.CorsHandler;
import io.netty.handler.stream.ChunkedWriteHandler;
import java.net.InetSocketAddress;
import lombok.extern.slf4j.Slf4j;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
/**
* flv流媒体服务
*
* @Author: orange
* @CreateTime: 2025-01-16
*/
@Slf4j
@Component
public class MediaServer {
@Autowired
private FlvHandler flvHandler;
public void start(InetSocketAddress socketAddress) {
//new 一个主线程组
EventLoopGroup bossGroup = new NioEventLoopGroup(1);
//new 一个工作线程组
EventLoopGroup workGroup = new NioEventLoopGroup(200);
ServerBootstrap bootstrap = new ServerBootstrap()
.group(bossGroup, workGroup)
.channel(NioServerSocketChannel.class)
.childHandler(new ChannelInitializer<SocketChannel>() {
@Override
protected void initChannel(SocketChannel socketChannel) throws Exception {
CorsConfig corsConfig = CorsConfigBuilder.forAnyOrigin().allowNullOrigin().allowCredentials().build();
socketChannel.pipeline()
.addLast(new HttpResponseEncoder())
.addLast(new HttpRequestDecoder())
.addLast(new ChunkedWriteHandler())
.addLast(new HttpObjectAggregator(64 * 1024))
.addLast(new CorsHandler(corsConfig))
.addLast(flvHandler);
}
})
.localAddress(socketAddress)
.option(ChannelOption.SO_BACKLOG, 128)
//首选直接内存
.option(ChannelOption.ALLOCATOR, PreferredDirectByteBufAllocator.DEFAULT)
// 两小时内没有数据的通信时,TCP会自动发送一个活动探测数据报文
.childOption(ChannelOption.TCP_NODELAY, true)
.childOption(ChannelOption.SO_KEEPALIVE, true)
.childOption(ChannelOption.SO_RCVBUF, 128 * 1024)
.childOption(ChannelOption.SO_SNDBUF, 1024 * 1024)
.childOption(ChannelOption.WRITE_BUFFER_WATER_MARK, new WriteBufferWaterMark(1024 * 1024 / 2, 1024 * 1024));
//绑定端口,开始接收进来的连接
try {
ChannelFuture future = bootstrap.bind(socketAddress).sync();
future.channel().closeFuture().sync();
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
//关闭主线程组
bossGroup.shutdownGracefully();
//关闭工作线程组
workGroup.shutdownGracefully();
}
}
}

View File

@@ -0,0 +1,112 @@
package com.ruoyi.video.service;
import cn.hutool.core.convert.Convert;
import cn.hutool.core.io.IoUtil;
import cn.hutool.crypto.digest.MD5;
import com.ruoyi.video.common.CacheMap;
import com.ruoyi.video.domain.dto.CameraDto;
import com.ruoyi.video.thread.MediaTransferHls;
import java.io.InputStream;
import java.util.concurrent.ConcurrentHashMap;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.env.Environment;
import org.springframework.stereotype.Service;
/**
* 处理hls
* @Author: orange
* @CreateTime: 2025-01-16
*/
@Service
public class HlsService {
@Autowired
private Environment env;
/**
*
*/
public static ConcurrentHashMap<String, MediaTransferHls> cameras = new ConcurrentHashMap<>();
/**
* 定义ts缓存10秒
*/
public static CacheMap<String, byte[]> cacheTs = new CacheMap<>(10000);
public static CacheMap<String, byte[]> cacheM3u8 = new CacheMap<>(10000);
/**
* 保存ts
*/
public void processTs(String mediaKey, String tsName, InputStream in) {
byte[] readBytes = IoUtil.readBytes(in);
String tsKey = mediaKey.concat("-").concat(tsName);
cacheTs.put(tsKey, readBytes);
}
/**
* 保存hls
* @param mediaKey
* @param in
*/
public void processHls(String mediaKey, InputStream in) {
byte[] readBytes = IoUtil.readBytes(in);
cacheM3u8.put(mediaKey, readBytes);
}
/**
* 关闭hls切片
*
* @param cameraDto
*/
public void closeConvertToHls(CameraDto cameraDto) {
// 区分不同媒体
String mediaKey = MD5.create().digestHex(cameraDto.getUrl());
if (cameras.containsKey(mediaKey)) {
MediaTransferHls mediaTransferHls = cameras.get(mediaKey);
mediaTransferHls.stop();
cameras.remove(mediaKey);
cacheTs.remove(mediaKey);
cacheM3u8.remove(mediaKey);
}
}
/**
* 开始hls切片
*
* @param cameraDto
* @return
*/
public boolean startConvertToHls(CameraDto cameraDto) {
// 区分不同媒体
String mediaKey = MD5.create().digestHex(cameraDto.getUrl());
cameraDto.setMediaKey(mediaKey);
MediaTransferHls mediaTransferHls = cameras.get(mediaKey);
if (null == mediaTransferHls) {
mediaTransferHls = new MediaTransferHls(cameraDto, Convert.toInt(env.getProperty("server.port")));
cameras.put(mediaKey, mediaTransferHls);
mediaTransferHls.execute();
}
mediaTransferHls = cameras.get(mediaKey);
// 15秒还没true认为启动不了
for (int i = 0; i < 30; i++) {
if (mediaTransferHls.isRunning()) {
return true;
}
try {
Thread.sleep(500);
} catch (InterruptedException e) {
}
}
return false;
}
}

View File

@@ -0,0 +1,62 @@
package com.ruoyi.video.service;
import com.ruoyi.video.domain.Device;
import java.util.List;
/**
* 设备Service接口
*
* @author orange
* @date 2025-01-14
*/
public interface IDeviceService
{
/**
* 查询设备
*
* @param deviceId 设备主键
* @return 设备
*/
public Device selectDeviceByDeviceId(Long deviceId);
/**
* 查询设备列表
*
* @param device 设备
* @return 设备集合
*/
public List<Device> selectDeviceList(Device device);
/**
* 新增设备
*
* @param device 设备
* @return 结果
*/
public int insertDevice(Device device);
/**
* 修改设备
*
* @param device 设备
* @return 结果
*/
public int updateDevice(Device device);
/**
* 批量删除设备
*
* @param deviceIds 需要删除的设备主键集合
* @return 结果
*/
public int deleteDeviceByDeviceIds(Long[] deviceIds);
/**
* 删除设备信息
*
* @param deviceId 设备主键
* @return 结果
*/
public int deleteDeviceByDeviceId(Long deviceId);
}

View File

@@ -0,0 +1,62 @@
package com.ruoyi.video.service;
import com.ruoyi.video.domain.VImage;
import java.util.List;
/**
* 图片存储Service接口
*
* @author orange
* @date 2025-01-16
*/
public interface IVImageService
{
/**
* 查询图片存储
*
* @param id 图片存储主键
* @return 图片存储
*/
public VImage selectVImageById(Long id);
/**
* 查询图片存储列表
*
* @param vImage 图片存储
* @return 图片存储集合
*/
public List<VImage> selectVImageList(VImage vImage);
/**
* 新增图片存储
*
* @param vImage 图片存储
* @return 结果
*/
public int insertVImage(VImage vImage);
/**
* 修改图片存储
*
* @param vImage 图片存储
* @return 结果
*/
public int updateVImage(VImage vImage);
/**
* 批量删除图片存储
*
* @param ids 需要删除的图片存储主键集合
* @return 结果
*/
public int deleteVImageByIds(Long[] ids);
/**
* 删除图片存储信息
*
* @param id 图片存储主键
* @return 结果
*/
public int deleteVImageById(Long id);
}

View File

@@ -0,0 +1,195 @@
package com.ruoyi.video.service;
import cn.hutool.core.thread.ThreadUtil;
import cn.hutool.crypto.digest.MD5;
import com.ruoyi.video.common.ClientType;
import com.ruoyi.video.domain.dto.CameraDto;
import com.ruoyi.video.thread.MediaTransfer;
import com.ruoyi.video.thread.MediaTransferFlvByFFmpeg;
import com.ruoyi.video.thread.MediaTransferFlvByJavacv;
import io.netty.channel.ChannelHandlerContext;
import java.util.concurrent.ConcurrentHashMap;
import org.springframework.stereotype.Service;
/**
* 媒体服务,支持全局网络超时、读写超时、无人拉流持续时长自动关闭流等配置
* @Author: orange
* @CreateTime: 2025-01-16
*/
@Service
public class MediaService {
/**
* 缓存流转换线程
*/
public static ConcurrentHashMap<String, MediaTransfer> cameras = new ConcurrentHashMap<>();
/**
* http-flv播放
* @param cameraDto
* @param ctx
*/
public void playForHttp(CameraDto cameraDto, ChannelHandlerContext ctx) {
if (cameras.containsKey(cameraDto.getMediaKey())) {
MediaTransfer mediaConvert = cameras.get(cameraDto.getMediaKey());
if(mediaConvert instanceof MediaTransferFlvByJavacv) {
MediaTransferFlvByJavacv mediaTransferFlvByJavacv = (MediaTransferFlvByJavacv) mediaConvert;
//如果当前已经用ffmpeg则重新拉流
if(cameraDto.isEnabledFFmpeg()) {
mediaTransferFlvByJavacv.setRunning(false);
cameras.remove(cameraDto.getMediaKey());
this.playForHttp(cameraDto, ctx);
} else {
mediaTransferFlvByJavacv.addClient(ctx, ClientType.HTTP);
}
} else if (mediaConvert instanceof MediaTransferFlvByFFmpeg) {
MediaTransferFlvByFFmpeg mediaTransferFlvByFFmpeg = (MediaTransferFlvByFFmpeg) mediaConvert;
//如果当前已经用javacv则关闭再重新拉流
if(!cameraDto.isEnabledFFmpeg()) {
mediaTransferFlvByFFmpeg.stopFFmpeg();
cameras.remove(cameraDto.getMediaKey());
this.playForHttp(cameraDto, ctx);
} else {
mediaTransferFlvByFFmpeg.addClient(ctx, ClientType.HTTP);
}
}
} else {
if(cameraDto.isEnabledFFmpeg()) {
MediaTransferFlvByFFmpeg mediaft = new MediaTransferFlvByFFmpeg(cameraDto);
mediaft.execute();
cameras.put(cameraDto.getMediaKey(), mediaft);
mediaft.addClient(ctx, ClientType.HTTP);
} else {
MediaTransferFlvByJavacv mediaConvert = new MediaTransferFlvByJavacv(cameraDto);
cameras.put(cameraDto.getMediaKey(), mediaConvert);
ThreadUtil.execute(mediaConvert);
mediaConvert.addClient(ctx, ClientType.HTTP);
}
}
}
/**
* ws-flv播放
* @param cameraDto
* @param ctx
*/
public void playForWs(CameraDto cameraDto, ChannelHandlerContext ctx) {
if (cameras.containsKey(cameraDto.getMediaKey())) {
MediaTransfer mediaConvert = cameras.get(cameraDto.getMediaKey());
if(mediaConvert instanceof MediaTransferFlvByJavacv) {
MediaTransferFlvByJavacv mediaTransferFlvByJavacv = (MediaTransferFlvByJavacv) mediaConvert;
//如果当前已经用ffmpeg则重新拉流
if(cameraDto.isEnabledFFmpeg()) {
mediaTransferFlvByJavacv.setRunning(false);
cameras.remove(cameraDto.getMediaKey());
this.playForWs(cameraDto, ctx);
} else {
mediaTransferFlvByJavacv.addClient(ctx, ClientType.WEBSOCKET);
}
} else if (mediaConvert instanceof MediaTransferFlvByFFmpeg) {
MediaTransferFlvByFFmpeg mediaTransferFlvByFFmpeg = (MediaTransferFlvByFFmpeg) mediaConvert;
//如果当前已经用javacv则关闭再重新拉流
if(!cameraDto.isEnabledFFmpeg()) {
mediaTransferFlvByFFmpeg.stopFFmpeg();
cameras.remove(cameraDto.getMediaKey());
this.playForWs(cameraDto, ctx);
} else {
mediaTransferFlvByFFmpeg.addClient(ctx, ClientType.WEBSOCKET);
}
}
} else {
if(cameraDto.isEnabledFFmpeg()) {
MediaTransferFlvByFFmpeg mediaft = new MediaTransferFlvByFFmpeg(cameraDto);
mediaft.execute();
cameras.put(cameraDto.getMediaKey(), mediaft);
mediaft.addClient(ctx, ClientType.WEBSOCKET);
} else {
MediaTransferFlvByJavacv mediaConvert = new MediaTransferFlvByJavacv(cameraDto);
cameras.put(cameraDto.getMediaKey(), mediaConvert);
ThreadUtil.execute(mediaConvert);
mediaConvert.addClient(ctx, ClientType.WEBSOCKET);
}
}
}
/**
* api播放
* @param cameraDto
* @return
*/
public boolean playForApi(CameraDto cameraDto) {
// 区分不同媒体
String mediaKey = MD5.create().digestHex(cameraDto.getUrl());
cameraDto.setMediaKey(mediaKey);
cameraDto.setEnabledFlv(true);
MediaTransfer mediaTransfer = cameras.get(cameraDto.getMediaKey());
if (null == mediaTransfer) {
if(cameraDto.isEnabledFFmpeg()) {
MediaTransferFlvByFFmpeg mediaft = new MediaTransferFlvByFFmpeg(cameraDto);
mediaft.execute();
cameras.put(cameraDto.getMediaKey(), mediaft);
} else {
MediaTransferFlvByJavacv mediaConvert = new MediaTransferFlvByJavacv(cameraDto);
cameras.put(cameraDto.getMediaKey(), mediaConvert);
ThreadUtil.execute(mediaConvert);
}
}
mediaTransfer = cameras.get(cameraDto.getMediaKey());
//同步等待
if(mediaTransfer instanceof MediaTransferFlvByJavacv) {
MediaTransferFlvByJavacv mediaft = (MediaTransferFlvByJavacv) mediaTransfer;
// 30秒还没true认为启动不了
for (int i = 0; i < 60; i++) {
if (mediaft.isRunning() && mediaft.isGrabberStatus() && mediaft.isRecorderStatus()) {
return true;
}
try {
Thread.sleep(500);
} catch (InterruptedException e) {
}
}
} else if (mediaTransfer instanceof MediaTransferFlvByFFmpeg) {
MediaTransferFlvByFFmpeg mediaft = (MediaTransferFlvByFFmpeg) mediaTransfer;
// 30秒还没true认为启动不了
for (int i = 0; i < 60; i++) {
if (mediaft.isRunning()) {
return true;
}
try {
Thread.sleep(500);
} catch (InterruptedException e) {
}
}
}
return false;
}
/**
* 关闭流
* @param cameraDto
*/
public void closeForApi(CameraDto cameraDto) {
cameraDto.setEnabledFlv(false);
if (cameras.containsKey(cameraDto.getMediaKey())) {
MediaTransfer mediaConvert = cameras.get(cameraDto.getMediaKey());
if(mediaConvert instanceof MediaTransferFlvByJavacv) {
MediaTransferFlvByJavacv mediaTransferFlvByJavacv = (MediaTransferFlvByJavacv) mediaConvert;
mediaTransferFlvByJavacv.setRunning(false);
cameras.remove(cameraDto.getMediaKey());
} else if (mediaConvert instanceof MediaTransferFlvByFFmpeg) {
MediaTransferFlvByFFmpeg mediaTransferFlvByFFmpeg = (MediaTransferFlvByFFmpeg) mediaConvert;
mediaTransferFlvByFFmpeg.stopFFmpeg();
cameras.remove(cameraDto.getMediaKey());
}
}
}
}

View File

@@ -0,0 +1,113 @@
package com.ruoyi.video.service.impl;
import com.ruoyi.common.utils.DateUtils;
import com.ruoyi.video.domain.Device;
import com.ruoyi.video.mapper.DeviceMapper;
import com.ruoyi.video.service.IDeviceService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.List;
/**
* 设备Service业务层处理
*
* @author orange
* @date 2025-01-14
*/
@Service
public class DeviceServiceImpl implements IDeviceService {
@Autowired
private DeviceMapper deviceMapper;
/**
* 查询设备
*
* @param deviceId 设备主键
* @return 设备
*/
@Override
public Device selectDeviceByDeviceId(Long deviceId) {
return deviceMapper.selectDeviceByDeviceId(deviceId);
}
/**
* 查询设备列表
*
* @param device 设备
* @return 设备
*/
@Override
public List<Device> selectDeviceList(Device device) {
return deviceMapper.selectDeviceList(device);
}
/**
* 新增设备
*
* @param device 设备
* @return 结果
*/
@Override
public int insertDevice(Device device) {
device.setUrl(this.generateRtspUrl(device));
device.setCreateTime(DateUtils.getNowDate());
return deviceMapper.insertDevice(device);
}
/**
* 修改设备
*
* @param device 设备
* @return 结果
*/
@Override
public int updateDevice(Device device) {
device.setUrl(this.generateRtspUrl(device));
device.setUpdateTime(DateUtils.getNowDate());
return deviceMapper.updateDevice(device);
}
/**
* 批量删除设备
*
* @param deviceIds 需要删除的设备主键
* @return 结果
*/
@Override
public int deleteDeviceByDeviceIds(Long[] deviceIds) {
return deviceMapper.deleteDeviceByDeviceIds(deviceIds);
}
/**
* 删除设备信息
*
* @param deviceId 设备主键
* @return 结果
*/
@Override
public int deleteDeviceByDeviceId(Long deviceId) {
return deviceMapper.deleteDeviceByDeviceId(deviceId);
}
/**
* 生成rtsp地址
* @param device
* @return
*/
private String generateRtspUrl(Device device) {
String ip = device.getIp();
String userName = device.getUserName();
String password = device.getPassword();
int port = 554;
if ("1".equals(device.getType())) {
return String.format("rtsp://%s:%s@%s:%d/Streaming/Channels/2", userName, password, ip, port);
} else if ("2".equals(device.getType())) {
return String.format("rtsp://%s:%s@%s:%d/cam/realmonitor?channel=1&subtype=0", userName, password, ip, port);
} else {
throw new RuntimeException("网络异常,请检查网络!");
}
}
}

View File

@@ -0,0 +1,97 @@
package com.ruoyi.video.service.impl;
import com.ruoyi.common.utils.DateUtils;
import com.ruoyi.video.domain.VImage;
import com.ruoyi.video.mapper.VImageMapper;
import com.ruoyi.video.service.IVImageService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.List;
/**
* 图片存储Service业务层处理
*
* @author orange
* @date 2025-01-16
*/
@Service
public class VImageServiceImpl implements IVImageService
{
@Autowired
private VImageMapper vImageMapper;
/**
* 查询图片存储
*
* @param id 图片存储主键
* @return 图片存储
*/
@Override
public VImage selectVImageById(Long id)
{
return vImageMapper.selectVImageById(id);
}
/**
* 查询图片存储列表
*
* @param vImage 图片存储
* @return 图片存储
*/
@Override
public List<VImage> selectVImageList(VImage vImage)
{
return vImageMapper.selectVImageList(vImage);
}
/**
* 新增图片存储
*
* @param vImage 图片存储
* @return 结果
*/
@Override
public int insertVImage(VImage vImage)
{
vImage.setCreateTime(DateUtils.getNowDate());
return vImageMapper.insertVImage(vImage);
}
/**
* 修改图片存储
*
* @param vImage 图片存储
* @return 结果
*/
@Override
public int updateVImage(VImage vImage)
{
vImage.setUpdateTime(DateUtils.getNowDate());
return vImageMapper.updateVImage(vImage);
}
/**
* 批量删除图片存储
*
* @param ids 需要删除的图片存储主键
* @return 结果
*/
@Override
public int deleteVImageByIds(Long[] ids)
{
return vImageMapper.deleteVImageByIds(ids);
}
/**
* 删除图片存储信息
*
* @param id 图片存储主键
* @return 结果
*/
@Override
public int deleteVImageById(Long id)
{
return vImageMapper.deleteVImageById(id);
}
}

View File

@@ -0,0 +1,10 @@
package com.ruoyi.video.thread;
/**
* @Author: orange
* @CreateTime: 2025-01-16
*/
public class MediaTransfer {
public MediaTransfer() {
}
}

View File

@@ -0,0 +1,587 @@
package com.ruoyi.video.thread;
import cn.hutool.core.collection.CollUtil;
import com.ruoyi.video.common.ClientType;
import com.ruoyi.video.common.MediaConstant;
import com.ruoyi.video.domain.dto.CameraDto;
import com.ruoyi.video.service.MediaService;
import io.netty.buffer.Unpooled;
import io.netty.channel.ChannelFuture;
import io.netty.channel.ChannelHandlerContext;
import io.netty.handler.codec.http.websocketx.BinaryWebSocketFrame;
import io.netty.util.concurrent.Future;
import io.netty.util.concurrent.GenericFutureListener;
import java.io.BufferedReader;
import java.io.ByteArrayOutputStream;
import java.io.DataInputStream;
import java.io.IOException;
import java.io.InputStreamReader;
import java.net.InetAddress;
import java.net.ServerSocket;
import java.net.Socket;
import java.net.SocketTimeoutException;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import lombok.extern.slf4j.Slf4j;
import org.bytedeco.javacv.FrameGrabber;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* @Author: orange
* @CreateTime: 2025-01-16
*/
@Slf4j
public class MediaTransferFlvByFFmpeg extends MediaTransfer {
/**
* ws客户端
*/
private ConcurrentHashMap<String, ChannelHandlerContext> wsClients = new ConcurrentHashMap<>();
/**
* http客户端
*/
private ConcurrentHashMap<String, ChannelHandlerContext> httpClients = new ConcurrentHashMap<>();
/**
* flv header
*/
private byte[] header = null;
/**
* 相机
*/
private CameraDto cameraDto;
private List<String> command = new ArrayList<>();
private ServerSocket tcpServer = null;
private Process process;
private Thread inputThread;
private Thread errThread;
private Thread outputThread;
private Thread listenThread;
private boolean running = false; // 启动
private boolean enableLog = true;
private int hcSize, wcSize = 0;
// 记录当前
long currentTimeMillis = System.currentTimeMillis();
/**
* 用于没有客户端时候的计时
*/
private int noClient = 0;
public MediaTransferFlvByFFmpeg(final String executable) {
command.add(executable);
buildCommand();
}
public MediaTransferFlvByFFmpeg(CameraDto cameraDto) {
command.add(System.getProperty(MediaConstant.ffmpegPathKey));
this.cameraDto = cameraDto;
buildCommand();
}
public MediaTransferFlvByFFmpeg(final String executable, CameraDto cameraDto) {
command.add(executable);
this.cameraDto = cameraDto;
buildCommand();
}
public MediaTransferFlvByFFmpeg(final String executable, CameraDto cameraDto, boolean enableLog) {
command.add(executable);
this.cameraDto = cameraDto;
this.enableLog = enableLog;
buildCommand();
}
public boolean isEnableLog() {
return enableLog;
}
public void setEnableLog(boolean enableLog) {
this.enableLog = enableLog;
}
public boolean isRunning() {
return running;
}
public void setRunning(boolean running) {
this.running = running;
}
private MediaTransferFlvByFFmpeg addArgument(String argument) {
command.add(argument);
return this;
}
/**
* 构建ffmpeg命令
*/
private void buildCommand() {
this.addArgument("-rtsp_transport").addArgument("tcp").addArgument("-i").addArgument(cameraDto.getUrl())
.addArgument("-max_delay").addArgument("1")
// .addArgument("-strict").addArgument("experimental")
.addArgument("-g").addArgument("25").addArgument("-r").addArgument("25")
// .addArgument("-b").addArgument("200000")
// .addArgument("-filter_complex").addArgument("setpts='(RTCTIME - RTCSTART) / (TB * 1000000)'")
.addArgument("-c:v").addArgument("libx264").addArgument("-preset:v").addArgument("ultrafast")
// .addArgument("-preset:v").addArgument("fast")
.addArgument("-tune:v").addArgument("zerolatency")
// .addArgument("-crf").addArgument("26")
.addArgument("-c:a").addArgument("aac")
// .addArgument("-qmin").addArgument("28")
// .addArgument("-qmax").addArgument("32")
// .addArgument("-b:v").addArgument("448k")
// .addArgument("-b:a").addArgument("64k")
.addArgument("-f").addArgument("flv");
}
// private void buildCommand() {
// this
//// .addArgument("-rtsp_transport").addArgument("tcp")
// .addArgument("-i").addArgument(camera.getUrl())
// .addArgument("-max_delay").addArgument("100")
//// .addArgument("-strict").addArgument("experimental")
// .addArgument("-g").addArgument("10")
//// .addArgument("-r").addArgument("25")
//// .addArgument("-b").addArgument("200000")
//// .addArgument("-filter_complex").addArgument("setpts='(RTCTIME - RTCSTART) / (TB * 1000000)'")
// .addArgument("-c:v").addArgument("libx264")
// .addArgument("-preset:v").addArgument("ultrafast")
// .addArgument("-tune:v").addArgument("zerolatency")
//// .addArgument("-crf").addArgument("26")
// .addArgument("-c:a").addArgument("aac")
// .addArgument("-qmin").addArgument("28")
// .addArgument("-qmax").addArgument("32")
// .addArgument("-b:v").addArgument("448k")
// .addArgument("-b:a").addArgument("64k")
// .addArgument("-f").addArgument("flv");
// }
/**
* 执行推流
*
* @return
*/
public MediaTransferFlvByFFmpeg execute() {
String output = getOutput();
command.add(output);
String join = CollUtil.join(command, " ");
log.info(join);
try {
process = new ProcessBuilder(command).start();
running = true;
listenNetTimeout();
dealStream(process);
outputData();
listenClient();
} catch (IOException e) {
e.printStackTrace();
}
return this;
}
/**
* flv数据
*/
private void outputData() {
outputThread = new Thread(new Runnable() {
public void run() {
Socket client = null;
try {
client = tcpServer.accept();
DataInputStream input = new DataInputStream(client.getInputStream());
byte[] buffer = new byte[1024];
int len = 0;
ByteArrayOutputStream bos = new ByteArrayOutputStream();
while (running) {
len = input.read(buffer);
if (len == -1) {
break;
}
bos.write(buffer, 0, len);
if (header == null) {
header = bos.toByteArray();
// System.out.println(HexUtil.encodeHexStr(header));
bos.reset();
continue;
}
// 帧数据
byte[] data = bos.toByteArray();
bos.reset();
// 发送到前端
sendFrameData(data);
}
try {
client.close();
} catch (java.lang.Exception e) {
}
try {
input.close();
} catch (java.lang.Exception e) {
}
try {
bos.close();
} catch (java.lang.Exception e) {
}
log.info("关闭媒体流-ffmpeg{} ", cameraDto.getUrl());
} catch (SocketTimeoutException e1) {
// e1.printStackTrace();
// 超时关闭
} catch (IOException e) {
// e.printStackTrace();
} finally {
MediaService.cameras.remove(cameraDto.getMediaKey());
running = false;
process.destroy();
try {
if (null != client) {
client.close();
}
} catch (IOException e) {
}
try {
if (null != tcpServer) {
tcpServer.close();
}
} catch (IOException e) {
}
}
}
});
outputThread.start();
}
/**
* 监听客户端
*/
public void listenClient() {
listenThread = new Thread(new Runnable() {
public void run() {
while (running) {
hasClient();
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
}
}
}
});
listenThread.start();
}
/**
* 监听网络异常超时
*/
public void listenNetTimeout() {
Thread listenNetTimeoutThread = new Thread(new Runnable() {
public void run() {
while (true) {
if ((System.currentTimeMillis() - currentTimeMillis) > 15000) {
log.info("网络异常超时");
MediaService.cameras.remove(cameraDto.getMediaKey());
stopFFmpeg();
break;
}
try {
Thread.sleep(5000);
} catch (InterruptedException e) {
}
}
}
});
listenNetTimeoutThread.setDaemon(true);
listenNetTimeoutThread.start();
}
public static MediaTransferFlvByFFmpeg atPath() {
return atPath(null);
}
public static MediaTransferFlvByFFmpeg atPath(final String absPath) {
final String executable;
if (absPath != null) {
executable = absPath;
} else {
executable = System.getProperty(MediaConstant.ffmpegPathKey);
}
return new MediaTransferFlvByFFmpeg(executable);
}
/**
* 控制台输出
*
* @param process
*/
private void dealStream(Process process) {
if (process == null) {
return;
}
// 处理InputStream的线程
inputThread = new Thread() {
@Override
public void run() {
BufferedReader in = new BufferedReader(new InputStreamReader(process.getInputStream()));
String line = null;
try {
while (running) {
line = in.readLine();
currentTimeMillis = System.currentTimeMillis();
if (line == null) {
break;
}
if (enableLog) {
log.info("output: " + line);
}
}
} catch (IOException e) {
e.printStackTrace();
} finally {
try {
running = false;
in.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
};
// 处理ErrorStream的线程
errThread = new Thread() {
@Override
public void run() {
BufferedReader err = new BufferedReader(new InputStreamReader(process.getErrorStream()));
String line = null;
try {
while (running) {
line = err.readLine();
currentTimeMillis = System.currentTimeMillis();
if (line == null) {
break;
}
if (enableLog) {
log.info("err: " + line);
}
}
} catch (IOException e) {
e.printStackTrace();
} finally {
try {
running = false;
err.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
};
inputThread.start();
errThread.start();
}
/**
* 输出到tcp
*
* @return
*/
private String getOutput() {
try {
tcpServer = new ServerSocket(0, 1, InetAddress.getLoopbackAddress());
StringBuffer sb = new StringBuffer();
sb.append("tcp://");
sb.append(tcpServer.getInetAddress().getHostAddress());
sb.append(":");
sb.append(tcpServer.getLocalPort());
tcpServer.setSoTimeout(10000);
return sb.toString();
} catch (IOException e) {
e.printStackTrace();
}
new RuntimeException("无法启用端口");
return "";
}
/**
* 关闭
*/
public void stopFFmpeg() {
this.running = false;
try {
this.process.destroy();
log.info("关闭媒体流-ffmpeg{} ", cameraDto.getUrl());
} catch (java.lang.Exception e) {
process.destroyForcibly();
}
// 媒体异常时,主动断开前端长连接
for (Map.Entry<String, ChannelHandlerContext> entry : wsClients.entrySet()) {
try {
entry.getValue().close();
} catch (java.lang.Exception e) {
} finally {
wsClients.remove(entry.getKey());
}
}
for (Map.Entry<String, ChannelHandlerContext> entry : httpClients.entrySet()) {
try {
entry.getValue().close();
} catch (java.lang.Exception e) {
} finally {
httpClients.remove(entry.getKey());
}
}
}
/**
* 关闭流
*
* @return
*/
public void hasClient() {
int newHcSize = httpClients.size();
int newWcSize = wsClients.size();
if (hcSize != newHcSize || wcSize != newWcSize) {
hcSize = newHcSize;
wcSize = newWcSize;
log.info("\r\n{}\r\nhttp连接数{}, ws连接数{} \r\n", cameraDto.getUrl(), newHcSize, newWcSize);
}
// 无需自动关闭
if (!cameraDto.isAutoClose()) {
return;
}
if (httpClients.isEmpty() && wsClients.isEmpty()) {
// 等待20秒还没有客户端则关闭推流
if (noClient > cameraDto.getNoClientsDuration()) {
running = false;
MediaService.cameras.remove(cameraDto.getMediaKey());
} else {
noClient += 1000;
}
} else {
// 重置计时
noClient = 0;
}
}
/**
* 发送帧数据
*
* @param data
*/
private void sendFrameData(byte[] data) {
// ws
for (Map.Entry<String, ChannelHandlerContext> entry : wsClients.entrySet()) {
try {
if (entry.getValue().channel().isWritable()) {
entry.getValue().writeAndFlush(new BinaryWebSocketFrame(Unpooled.copiedBuffer(data)));
} else {
wsClients.remove(entry.getKey());
hasClient();
}
} catch (java.lang.Exception e) {
wsClients.remove(entry.getKey());
hasClient();
e.printStackTrace();
}
}
// http
for (Map.Entry<String, ChannelHandlerContext> entry : httpClients.entrySet()) {
try {
if (entry.getValue().channel().isWritable()) {
entry.getValue().writeAndFlush(Unpooled.copiedBuffer(data));
} else {
httpClients.remove(entry.getKey());
hasClient();
}
} catch (java.lang.Exception e) {
httpClients.remove(entry.getKey());
hasClient();
e.printStackTrace();
}
}
}
/**
* 新增客户端
*
* @param ctx netty client
* @param ctype enum,ClientType
*/
public void addClient(ChannelHandlerContext ctx, ClientType ctype) {
int timeout = 0;
while (true) {
try {
if (header != null) {
try {
if (ctx.channel().isWritable()) {
// 发送帧前先发送header
if (ClientType.HTTP.getType() == ctype.getType()) {
ChannelFuture future = ctx.writeAndFlush(Unpooled.copiedBuffer(header));
future.addListener(new GenericFutureListener<Future<? super Void>>() {
@Override
public void operationComplete(Future<? super Void> future) throws FrameGrabber.Exception {
if (future.isSuccess()) {
httpClients.put(ctx.channel().id().toString(), ctx);
}
}
});
} else if (ClientType.WEBSOCKET.getType() == ctype.getType()) {
ChannelFuture future = ctx
.writeAndFlush(new BinaryWebSocketFrame(Unpooled.copiedBuffer(header)));
future.addListener(new GenericFutureListener<Future<? super Void>>() {
@Override
public void operationComplete(Future<? super Void> future) throws FrameGrabber.Exception {
if (future.isSuccess()) {
wsClients.put(ctx.channel().id().toString(), ctx);
}
}
});
}
}
} catch (java.lang.Exception e) {
e.printStackTrace();
}
break;
}
// 等待推拉流启动
Thread.sleep(50);
// 启动录制器失败
timeout += 50;
if (timeout > 30000) {
break;
}
} catch (java.lang.Exception e) {
e.printStackTrace();
}
}
}
}

View File

@@ -0,0 +1,576 @@
package com.ruoyi.video.thread;
import com.arcsoft.face.toolkit.ImageInfo;
import com.ruoyi.common.utils.spring.SpringUtils;
import com.ruoyi.video.common.ClientType;
import com.ruoyi.video.domain.dto.CameraDto;
import com.ruoyi.video.service.MediaService;
import com.ruoyi.video.utils.ArcFaceEngineUtil;
import io.netty.buffer.Unpooled;
import io.netty.channel.ChannelFuture;
import io.netty.channel.ChannelHandlerContext;
import io.netty.handler.codec.http.websocketx.BinaryWebSocketFrame;
import io.netty.util.concurrent.Future;
import io.netty.util.concurrent.GenericFutureListener;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import lombok.extern.slf4j.Slf4j;
import org.bytedeco.ffmpeg.avcodec.AVPacket;
import org.bytedeco.ffmpeg.global.avcodec;
import org.bytedeco.ffmpeg.global.avutil;
import org.bytedeco.javacv.FFmpegFrameGrabber;
import org.bytedeco.javacv.FFmpegFrameRecorder;
import org.bytedeco.javacv.FFmpegLogCallback;
import org.bytedeco.javacv.Frame;
import org.bytedeco.javacv.FrameGrabber;
import org.springframework.scheduling.annotation.Async;
/**
* @Author: orange
* @CreateTime: 2025-01-16
*/
@Slf4j
public class MediaTransferFlvByJavacv extends MediaTransfer implements Runnable {
static {
avutil.av_log_set_level(avutil.AV_LOG_ERROR);
FFmpegLogCallback.set();
}
/**
* ws客户端
*/
private ConcurrentHashMap<String, ChannelHandlerContext> wsClients = new ConcurrentHashMap<>();
/**
* http客户端
*/
private ConcurrentHashMap<String, ChannelHandlerContext> httpClients = new ConcurrentHashMap<>();
/**
* 运行状态
*/
private volatile boolean running = false;
private boolean grabberStatus = false;
private boolean recorderStatus = false;
/**
* 当前在线人数
*/
private int hcSize, wcSize = 0;
/**
* 用于没有客户端时候的计时
*/
private int noClient = 0;
/**
* flv header
*/
private byte[] header = null;
// 输出流,视频最终会输出到此
private ByteArrayOutputStream bos = new ByteArrayOutputStream();
FFmpegFrameGrabber grabber;// 拉流器
FFmpegFrameRecorder recorder;// 推流录制器
/**
* true:转复用,false:转码
*/
boolean transferFlag = false;// 默认转码
/**
* 相机
*/
private CameraDto cameraDto;
/**
* 监听线程,用于监听状态
*/
private Thread listenThread;
private ArcFaceEngineUtil arcFaceEngineUtil;
public MediaTransferFlvByJavacv(CameraDto cameraDto) {
super();
this.cameraDto = cameraDto;
this.arcFaceEngineUtil = SpringUtils.getBean(ArcFaceEngineUtil.class);
}
public boolean isRunning() {
return running;
}
public void setRunning(boolean running) {
this.running = running;
}
public boolean isGrabberStatus() {
return grabberStatus;
}
public void setGrabberStatus(boolean grabberStatus) {
this.grabberStatus = grabberStatus;
}
public boolean isRecorderStatus() {
return recorderStatus;
}
public void setRecorderStatus(boolean recorderStatus) {
this.recorderStatus = recorderStatus;
}
/**
* 创建拉流器
*
* @return
*/
protected boolean createGrabber() {
// 拉流器
grabber = new FFmpegFrameGrabber(cameraDto.getUrl());
// 超时时间(15秒)
grabber.setOption("stimeout", cameraDto.getNetTimeout());
grabber.setOption("threads", "1");
// grabber.setPixelFormat(avutil.AV_PIX_FMT_YUV420P);
// 设置缓存大小,提高画质、减少卡顿花屏
grabber.setOption("buffer_size", "1024000");
// 读写超时,适用于所有协议的通用读写超时
grabber.setOption("rw_timeout", cameraDto.getReadOrWriteTimeout());
// 探测视频流信息为空默认5000000微秒
grabber.setOption("probesize", cameraDto.getReadOrWriteTimeout());
// 解析视频流信息为空默认5000000微秒
grabber.setOption("analyzeduration", cameraDto.getReadOrWriteTimeout());
// 如果为rtsp流增加配置
if ("rtsp".equals(cameraDto.getUrl().substring(0, 4))) {
// 设置打开协议tcp / udp
grabber.setOption("rtsp_transport", "tcp");
// 首选TCP进行RTP传输
grabber.setOption("rtsp_flags", "prefer_tcp");
} else if ("rtmp".equals(cameraDto.getUrl().substring(0, 4))) {
// rtmp拉流缓冲区默认3000毫秒
grabber.setOption("rtmp_buffer", "1000");
// 默认rtmp流为直播模式不允许seek
// grabber.setOption("rtmp_live", "live");
} else if ("desktop".equals(cameraDto.getUrl())) {
// 支持本地屏幕采集可以用于监控屏幕、局域网和wifi投屏等
grabber.setFormat("gdigrab");
grabber.setOption("draw_mouse", "1");// 绘制鼠标
grabber.setNumBuffers(0);
grabber.setOption("fflags", "nobuffer");
grabber.setOption("framerate", "25");
grabber.setFrameRate(25);
}
try {
grabber.start();
log.info("\r\n{}\r\n启动拉流器成功", cameraDto.getUrl());
return grabberStatus = true;
} catch (FrameGrabber.Exception e) {
MediaService.cameras.remove(cameraDto.getMediaKey());
log.error("\r\n{}\r\n启动拉流器失败网络超时或视频源不可用", cameraDto.getUrl());
// e.printStackTrace();
}
return grabberStatus = false;
}
/**
* 创建转码推流录制器
*
* @return
*/
protected boolean createTransterOrRecodeRecorder() {
recorder = new FFmpegFrameRecorder(bos, grabber.getImageWidth(), grabber.getImageHeight(),
grabber.getAudioChannels());
recorder.setFormat("flv");
if (!transferFlag) {
// 转码
recorder.setInterleaved(false);
recorder.setVideoOption("tune", "zerolatency");
recorder.setVideoOption("preset", "ultrafast");
recorder.setVideoOption("crf", "26");
recorder.setVideoOption("threads", "1");
recorder.setFrameRate(25);// 设置帧率
recorder.setGopSize(25);// 设置gop,与帧率相同相当于间隔1秒chan's一个关键帧
// recorder.setVideoBitrate(500 * 1000);// 码率500kb/s
// recorder.setVideoCodecName("libx264"); //javacv 1.5.5无法使用libx264名称请使用下面方法
recorder.setVideoCodec(avcodec.AV_CODEC_ID_H264);
recorder.setPixelFormat(avutil.AV_PIX_FMT_YUV420P);
recorder.setAudioCodec(avcodec.AV_CODEC_ID_AAC);
// recorder.setAudioCodecName("aac");
/**
* 启用RDOQ算法优化视频质量 1在视频码率和视频质量之间取得平衡 2最大程度优化视频质量会降低编码速度和提高码率
*/
recorder.setTrellis(1);
recorder.setMaxDelay(0);// 设置延迟
try {
recorder.start();
return recorderStatus = true;
} catch (org.bytedeco.javacv.FrameRecorder.Exception e1) {
log.info("启动转码录制器失败", e1);
MediaService.cameras.remove(cameraDto.getMediaKey());
e1.printStackTrace();
}
} else {
// 转复用
// 不让recorder关联关闭outputStream
recorder.setCloseOutputStream(false);
try {
recorder.start(grabber.getFormatContext());
return recorderStatus = true;
} catch (org.bytedeco.javacv.FrameRecorder.Exception e) {
log.warn("\r\n{}\r\n启动转复用录制器失败", cameraDto.getUrl());
// 如果转复用失败,则自动切换到转码模式
transferFlag = false;
if (recorder != null) {
try {
recorder.stop();
} catch (org.bytedeco.javacv.FrameRecorder.Exception e1) {
}
}
if (createTransterOrRecodeRecorder()) {
log.error("\r\n{}\r\n切换到转码模式", cameraDto.getUrl());
return true;
}
log.error("\r\n{}\r\n切换转码模式失败", cameraDto.getUrl());
e.printStackTrace();
}
}
return recorderStatus = false;
}
/**
* 是否支持flv的音视频编码
*
* @return
*/
private boolean supportFlvFormatCodec() {
int vcodec = grabber.getVideoCodec();
int acodec = grabber.getAudioCodec();
return (cameraDto.getType() == 0)
&& ("desktop".equals(cameraDto.getUrl()) || avcodec.AV_CODEC_ID_H264 == vcodec
|| avcodec.AV_CODEC_ID_H263 == vcodec)
&& (avcodec.AV_CODEC_ID_AAC == acodec || avcodec.AV_CODEC_ID_AAC_LATM == acodec);
}
/**
* 将视频源转换为flv
*/
protected void transferStream2Flv() {
if (!createGrabber()) {
return;
}
transferFlag = supportFlvFormatCodec();
if (!createTransterOrRecodeRecorder()) {
return;
}
try {
grabber.flush();
} catch (FrameGrabber.Exception e) {
log.info("清空拉流器缓存失败", e);
e.printStackTrace();
}
if (header == null) {
header = bos.toByteArray();
// System.out.println(HexUtil.encodeHexStr(header));
bos.reset();
}
running = true;
// 启动监听线程(用于判断是否需要自动关闭推流)
listenClient();
// 时间戳计算
long startTime = 0;
long videoTS = 0;
for (; running && grabberStatus && recorderStatus; ) {
try {
if (transferFlag) {
// 转复用
long startGrab = System.currentTimeMillis();
AVPacket pkt = grabber.grabPacket();
if ((System.currentTimeMillis() - startGrab) > 5000) {
log.info("\r\n{}\r\n视频流网络异常>>>", cameraDto.getUrl());
closeMedia();
break;
}
if (null != pkt && !pkt.isNull()) {
if (startTime == 0) {
startTime = System.currentTimeMillis();
}
videoTS = 1000 * (System.currentTimeMillis() - startTime);
// 判断时间偏移
if (videoTS > recorder.getTimestamp()) {
recorder.setTimestamp((videoTS));
}
recorder.recordPacket(pkt);
}
} else {
// 转码
long startGrab = System.currentTimeMillis();
Frame frame = grabber.grab();
if ((System.currentTimeMillis() - startGrab) > 5000) {
log.info("\r\n{}\r\n视频流网络异常>>>", cameraDto.getUrl());
closeMedia();
break;
}
if (frame != null) {
processFaceDetection(frame);
if (startTime == 0) {
startTime = System.currentTimeMillis();
}
videoTS = 1000 * (System.currentTimeMillis() - startTime);
// 判断时间偏移
if (videoTS > recorder.getTimestamp()) {
// System.out.println("矫正时间戳: " + videoTS + " : " + recorder.getTimestamp() + "
// -> "
// + (videoTS - recorder.getTimestamp()));
recorder.setTimestamp((videoTS));
}
recorder.record(frame);
}
}
} catch (FrameGrabber.Exception e) {
grabberStatus = false;
MediaService.cameras.remove(cameraDto.getMediaKey());
} catch (org.bytedeco.javacv.FrameRecorder.Exception e) {
recorderStatus = false;
MediaService.cameras.remove(cameraDto.getMediaKey());
}
if (bos.size() > 0) {
byte[] b = bos.toByteArray();
bos.reset();
// 发送视频到前端
sendFrameData(b);
}
}
// 启动失败,直接关闭, close包含stop和release方法。录制文件必须保证最后执行stop()方法
try {
recorder.close();
grabber.close();
bos.close();
} catch (org.bytedeco.javacv.FrameRecorder.Exception e) {
e.printStackTrace();
} catch (FrameGrabber.Exception e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
} finally {
closeMedia();
}
log.info("关闭媒体流-javacv{} ", cameraDto.getUrl());
}
@Async
public void processFaceDetection(Frame frame) {
if (arcFaceEngineUtil != null) {
ImageInfo imageInfo = arcFaceEngineUtil.frameToImageInfo(frame);
if (imageInfo != null) {
arcFaceEngineUtil.getUserInfo(imageInfo);
}
}
}
/**
* 发送帧数据
*
* @param data
*/
private void sendFrameData(byte[] data) {
// ws
for (Map.Entry<String, ChannelHandlerContext> entry : wsClients.entrySet()) {
try {
if (entry.getValue().channel().isWritable()) {
entry.getValue().writeAndFlush(new BinaryWebSocketFrame(Unpooled.copiedBuffer(data)));
} else {
wsClients.remove(entry.getKey());
hasClient();
}
} catch (java.lang.Exception e) {
wsClients.remove(entry.getKey());
hasClient();
e.printStackTrace();
}
}
// http
for (Map.Entry<String, ChannelHandlerContext> entry : httpClients.entrySet()) {
try {
if (entry.getValue().channel().isWritable()) {
entry.getValue().writeAndFlush(Unpooled.copiedBuffer(data));
} else {
httpClients.remove(entry.getKey());
hasClient();
}
} catch (java.lang.Exception e) {
httpClients.remove(entry.getKey());
hasClient();
e.printStackTrace();
}
}
}
/**
* 判断有没有客户端,关闭流
*
* @return
*/
public void hasClient() {
int newHcSize = httpClients.size();
int newWcSize = wsClients.size();
if (hcSize != newHcSize || wcSize != newWcSize) {
hcSize = newHcSize;
wcSize = newWcSize;
log.info("\r\n{}\r\nhttp连接数{}, ws连接数{} \r\n", cameraDto.getUrl(), newHcSize, newWcSize);
}
// 无需自动关闭
if (!cameraDto.isAutoClose()) {
return;
}
if (httpClients.isEmpty() && wsClients.isEmpty()) {
// 等待20秒还没有客户端则关闭推流
if (noClient > cameraDto.getNoClientsDuration()) {
closeMedia();
} else {
noClient += 1000;
// log.info("\r\n{}\r\n {} 秒自动关闭推拉流 \r\n", camera.getUrl(), noClientsDuration-noClient);
}
} else {
// 重置计时
noClient = 0;
}
}
/**
* 监听客户端,用于判断无人观看时自动关闭推流
*/
public void listenClient() {
listenThread = new Thread(new Runnable() {
public void run() {
while (running) {
hasClient();
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
}
}
}
});
listenThread.start();
}
/**
* 关闭流媒体
*/
private void closeMedia() {
running = false;
MediaService.cameras.remove(cameraDto.getMediaKey());
// 媒体异常时,主动断开前端长连接
for (Map.Entry<String, ChannelHandlerContext> entry : wsClients.entrySet()) {
try {
entry.getValue().close();
} catch (java.lang.Exception e) {
} finally {
wsClients.remove(entry.getKey());
}
}
for (Map.Entry<String, ChannelHandlerContext> entry : httpClients.entrySet()) {
try {
entry.getValue().close();
} catch (java.lang.Exception e) {
} finally {
httpClients.remove(entry.getKey());
}
}
}
/**
* 新增客户端
*
* @param ctx netty client
* @param ctype enum,ClientType
*/
public void addClient(ChannelHandlerContext ctx, ClientType ctype) {
int timeout = 0;
while (true) {
try {
if (header != null) {
try {
if (ctx.channel().isWritable()) {
// 发送帧前先发送header
if (ClientType.HTTP.getType() == ctype.getType()) {
ChannelFuture future = ctx.writeAndFlush(Unpooled.copiedBuffer(header));
future.addListener(new GenericFutureListener<Future<? super Void>>() {
@Override
public void operationComplete(Future<? super Void> future) throws FrameGrabber.Exception {
if (future.isSuccess()) {
httpClients.put(ctx.channel().id().toString(), ctx);
}
}
});
} else if (ClientType.WEBSOCKET.getType() == ctype.getType()) {
ChannelFuture future = ctx
.writeAndFlush(new BinaryWebSocketFrame(Unpooled.copiedBuffer(header)));
future.addListener(new GenericFutureListener<Future<? super Void>>() {
@Override
public void operationComplete(Future<? super Void> future) throws FrameGrabber.Exception {
if (future.isSuccess()) {
wsClients.put(ctx.channel().id().toString(), ctx);
}
}
});
}
}
} catch (java.lang.Exception e) {
e.printStackTrace();
}
break;
}
// 等待推拉流启动
Thread.sleep(50);
// 启动录制器失败
timeout += 50;
if (timeout > 30000) {
break;
}
} catch (java.lang.Exception e) {
e.printStackTrace();
}
}
}
@Override
public void run() {
transferStream2Flv();
}
}

View File

@@ -0,0 +1,193 @@
package com.ruoyi.video.thread;
import cn.hutool.core.collection.CollUtil;
import com.ruoyi.video.common.MediaConstant;
import com.ruoyi.video.domain.dto.CameraDto;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.util.ArrayList;
import java.util.List;
import lombok.extern.slf4j.Slf4j;
/**
* hls切片
*/
@Slf4j
public class MediaTransferHls extends MediaTransfer {
/**
* 运行状态
*/
private boolean running = false;
private boolean enableLog = false;
private Process process;
private Thread inputThread;
private Thread errThread;
private int port = 8888;
/**
* 相机
*/
private CameraDto cameraDto;
/**
* cmd
*/
private List<String> command = new ArrayList<>();
public boolean isRunning() {
return running;
}
public void setRunning(boolean running) {
this.running = running;
}
/**
*
* @param cameraDto
*/
public MediaTransferHls(CameraDto cameraDto, int port) {
this.cameraDto = cameraDto;
this.port = port;
buildCommand();
}
/**
* String cmd = "ffmpeg -i rtsp://admin:VZCDOY@192.168.2.120:554/Streaming/Channels/102 -r 25 -g 25 -c:v libx264 -c:a aac -f hls -hls_list_size 1 -hls_wrap 6 -hls_time 1 -hls_base_url /ts/"+22+"/ -method put http://localhost:8888/record/"+22+"/out.m3u8";
*/
private void buildCommand() {
command.add(System.getProperty(MediaConstant.ffmpegPathKey));
command.add("-i");
command.add(cameraDto.getUrl());
command.add("-r");
command.add("25");
command.add("-g");
command.add("25");
command.add("-c:v");
command.add("h264"); //javacv 1.5.5 无法使用libx264
command.add("-c:a");
command.add("aac");
command.add("-f");
command.add("hls");
command.add("-hls_list_size");
command.add("1");
command.add("-hls_wrap");
command.add("6");
command.add("-hls_time");
command.add("1");
command.add("-hls_base_url");
command.add("/ts/"+cameraDto.getMediaKey()+"/");
command.add("-method");
command.add("put");
command.add("http://localhost:"+port+"/record/"+cameraDto.getMediaKey()+"/out.m3u8");
}
/**
* 执行
*/
public void execute() {
String join = CollUtil.join(command, " ");
log.info(join);
try {
process = new ProcessBuilder(command).start();
running = true;
dealStream(process);
} catch (IOException e) {
running = false;
e.printStackTrace();
}
}
/**
* 关闭
*/
public void stop() {
this.running = false;
try {
process.destroy();
log.info("关闭媒体流-ffmpeg{} ", cameraDto.getUrl());
} catch (Exception e) {
process.destroyForcibly();
}
}
/**
* 控制台输出
*
* @param process
*/
private void dealStream(Process process) {
if (process == null) {
return;
}
// 处理InputStream的线程
inputThread = new Thread() {
@Override
public void run() {
BufferedReader in = new BufferedReader(new InputStreamReader(process.getInputStream()));
String line = null;
try {
while (running) {
line = in.readLine();
if (line == null) {
break;
}
if (enableLog) {
log.info("output: " + line);
}
}
} catch (IOException e) {
e.printStackTrace();
} finally {
try {
running = false;
in.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
};
// 处理ErrorStream的线程
errThread = new Thread() {
@Override
public void run() {
BufferedReader err = new BufferedReader(new InputStreamReader(process.getErrorStream()));
String line = null;
try {
while (running) {
line = err.readLine();
if (line == null) {
break;
}
if (enableLog) {
log.info("err: " + line);
}
}
} catch (IOException e) {
e.printStackTrace();
} finally {
try {
running = false;
err.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
};
inputThread.start();
errThread.start();
}
}

View File

@@ -0,0 +1,286 @@
package com.ruoyi.video.utils;
import com.arcsoft.face.*;
import com.arcsoft.face.enums.DetectMode;
import com.arcsoft.face.enums.DetectOrient;
import com.arcsoft.face.enums.ErrorInfo;
import com.arcsoft.face.enums.ImageFormat;
import com.arcsoft.face.toolkit.ImageFactory;
import com.arcsoft.face.toolkit.ImageInfo;
import com.ruoyi.common.config.RuoYiConfig;
import com.ruoyi.common.utils.file.FileUploadUtils;
import com.ruoyi.video.domain.VImage;
import com.ruoyi.video.service.IVImageService;
import jakarta.annotation.PostConstruct;
import lombok.extern.slf4j.Slf4j;
import org.bytedeco.javacv.Frame;
import org.bytedeco.javacv.Java2DFrameConverter;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartFile;
import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.awt.image.DataBufferByte;
import java.io.File;
import java.io.IOException;
import java.io.PrintStream;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.concurrent.atomic.AtomicLong;
/**
* @Author: orange
* @CreateTime: 2025-01-16
*/
@Slf4j
@Component
public class ArcFaceEngineUtil {
@Value("${arcFace.appId}")
public String appId;
@Value("${arcFace.sdkKey}")
public String sdkKey;
int errorCode;
String projectPath = System.getProperty("user.dir");
@Autowired
private IVImageService ivImageService;
FaceEngine faceEngine = new FaceEngine(projectPath+"\\ruoyi-video\\src\\main\\resources\\libs\\WIN64");
private final AtomicLong lastSaveTime = new AtomicLong(0); // 记录上次保存的时间戳
private static final long SAVE_INTERVAL = 10 * 1000; // 10 秒的间隔
/**
* 初始化
*/
@PostConstruct
public void init() {
this.errorCode = this.faceEngine.activeOnline(this.appId, this.sdkKey);
if (this.errorCode != ErrorInfo.MOK.getValue() && this.errorCode != ErrorInfo.MERR_ASF_ALREADY_ACTIVATED.getValue()) {
log.error("引擎激活失败");
}
ActiveFileInfo activeFileInfo = new ActiveFileInfo();
this.errorCode = this.faceEngine.getActiveFileInfo(activeFileInfo);
if (this.errorCode != ErrorInfo.MOK.getValue() && this.errorCode != ErrorInfo.MERR_ASF_ALREADY_ACTIVATED.getValue()) {
log.error("获取激活文件信息失败");
}
EngineConfiguration engineConfiguration = new EngineConfiguration();
engineConfiguration.setDetectMode(DetectMode.ASF_DETECT_MODE_IMAGE);
engineConfiguration.setDetectFaceOrientPriority(DetectOrient.ASF_OP_ALL_OUT);
engineConfiguration.setDetectFaceMaxNum(10);
engineConfiguration.setDetectFaceScaleVal(16);
FunctionConfiguration functionConfiguration = new FunctionConfiguration();
functionConfiguration.setSupportAge(true);
functionConfiguration.setSupportFace3dAngle(true);
functionConfiguration.setSupportFaceDetect(true);
functionConfiguration.setSupportFaceRecognition(true);
functionConfiguration.setSupportGender(true);
functionConfiguration.setSupportLiveness(true);
functionConfiguration.setSupportIRLiveness(true);
engineConfiguration.setFunctionConfiguration(functionConfiguration);
this.errorCode = this.faceEngine.init(engineConfiguration);
if (this.errorCode != ErrorInfo.MOK.getValue()) {
log.error("初始化引擎失败");
}
log.info("------------------------");
log.info("ruoyi-video初始化引擎成功");
log.info("微信: chenbai0511");
log.info("请我喝杯咖啡吧qwq");
log.info("------------------------");
}
/**
* Frame对象转换为ImageInfo对象
* @param frame
* @return
*/
public ImageInfo frameToImageInfo(Frame frame) {
if (frame == null || frame.image == null) {
log.error("Frame是空或非图像帧------");
return null;
} else {
Java2DFrameConverter converter = new Java2DFrameConverter();
BufferedImage image = converter.convert(frame);
if (image == null) {
log.error("Converted image is null");
return null;
} else {
int width = image.getWidth();
int height = image.getHeight();
byte[] imageData = ((DataBufferByte)image.getRaster().getDataBuffer()).getData();
ImageInfo imageInfo = new ImageInfo();
imageInfo.setWidth(width);
imageInfo.setHeight(height);
imageInfo.setImageFormat(ImageFormat.CP_PAF_BGR24);
imageInfo.setImageData(imageData);
return imageInfo;
}
}
}
/**
* 虹软检测代码
* @param imageInfo
* @return
*/
public HashMap<String, Object> getUserInfo(ImageInfo imageInfo) {
ImageInfo imageInfoFc = ImageFactory.getRGBData(new File("D:\\cat\\fc.jpg"));
List<FaceInfo> faceInfoListFc = new ArrayList();
this.faceEngine.detectFaces(imageInfoFc.getImageData(), imageInfoFc.getWidth(), imageInfoFc.getHeight(), imageInfoFc.getImageFormat(), faceInfoListFc);
if (faceInfoListFc.isEmpty()) {
log.error("未检测到人脸faceInfoListFc");
return null;
} else {
FaceFeature faceFeatureFc = new FaceFeature();
this.faceEngine.extractFaceFeature(imageInfoFc.getImageData(), imageInfoFc.getWidth(), imageInfoFc.getHeight(), imageInfoFc.getImageFormat(), (FaceInfo)faceInfoListFc.get(0), faceFeatureFc);
List<FaceInfo> faceInfoList = new ArrayList();
this.errorCode = this.faceEngine.detectFaces(imageInfo.getImageData(), imageInfo.getWidth(), imageInfo.getHeight(), imageInfo.getImageFormat(), faceInfoList);
if (faceInfoList.isEmpty()) {
log.error("未检测到人脸faceInfoList");
return null;
} else {
FaceFeature faceFeature = new FaceFeature();
this.faceEngine.extractFaceFeature(imageInfo.getImageData(), imageInfo.getWidth(), imageInfo.getHeight(), imageInfo.getImageFormat(), (FaceInfo)faceInfoList.get(0), faceFeature);
FaceFeature targetFaceFeature = new FaceFeature();
targetFaceFeature.setFeatureData(faceFeatureFc.getFeatureData());
FaceFeature sourceFaceFeature = new FaceFeature();
sourceFaceFeature.setFeatureData(faceFeature.getFeatureData());
FaceSimilar faceSimilar = new FaceSimilar();
this.faceEngine.compareFaceFeature(targetFaceFeature, sourceFaceFeature, faceSimilar);
System.out.println("相似度:" + faceSimilar.getScore());
if (faceSimilar.getScore() < 0.8F) {
this.saveImage(imageInfo);
}
this.errorCode = this.faceEngine.setLivenessParam(0.5F, 0.7F);
FunctionConfiguration configuration = new FunctionConfiguration();
configuration.setSupportAge(true);
configuration.setSupportFace3dAngle(true);
configuration.setSupportGender(true);
configuration.setSupportLiveness(true);
this.errorCode = this.faceEngine.process(imageInfo.getImageData(), imageInfo.getWidth(), imageInfo.getHeight(), imageInfo.getImageFormat(), faceInfoList, configuration);
HashMap<String, Object> map = new HashMap();
List<GenderInfo> genderInfoList = new ArrayList();
this.errorCode = this.faceEngine.getGender(genderInfoList);
if (!genderInfoList.isEmpty()) {
System.out.println("性别:" + ((GenderInfo)genderInfoList.get(0)).getGender());
map.put("gender", ((GenderInfo)genderInfoList.get(0)).getGender());
} else {
log.error("未获取到性别信息");
}
List<AgeInfo> ageInfoList = new ArrayList();
this.errorCode = this.faceEngine.getAge(ageInfoList);
if (!ageInfoList.isEmpty()) {
System.out.println("年龄:" + ((AgeInfo)ageInfoList.get(0)).getAge());
map.put("age", ((AgeInfo)ageInfoList.get(0)).getAge());
} else {
log.error("未获取到年龄信息");
}
List<Face3DAngle> face3DAngleList = new ArrayList();
this.errorCode = this.faceEngine.getFace3DAngle(face3DAngleList);
if (!face3DAngleList.isEmpty()) {
PrintStream var10000 = System.out;
float var10001 = ((Face3DAngle)face3DAngleList.get(0)).getPitch();
var10000.println("3D角度" + var10001 + "," + ((Face3DAngle)face3DAngleList.get(0)).getRoll() + "," + ((Face3DAngle)face3DAngleList.get(0)).getYaw());
map.put("3DAngle", face3DAngleList.get(0));
} else {
log.error("未获取到3D角度信息");
}
List<LivenessInfo> livenessInfoList = new ArrayList();
this.errorCode = this.faceEngine.getLiveness(livenessInfoList);
if (!livenessInfoList.isEmpty()) {
System.out.println("活体:" + ((LivenessInfo)livenessInfoList.get(0)).getLiveness());
map.put("liveness", ((LivenessInfo)livenessInfoList.get(0)).getLiveness());
} else {
log.error("未获取到活体信息");
}
return map;
}
}
}
/**
* 保存图片
* @param imageInfo
*/
private void saveImage(ImageInfo imageInfo) {
long currentTime = System.currentTimeMillis();
long lastTime = lastSaveTime.get();
if (currentTime - lastTime < SAVE_INTERVAL) {
log.info("10 秒内已保存过照片,跳过本次保存");
return;
}
if (!lastSaveTime.compareAndSet(lastTime, currentTime)) {
return;
}
try {
File dir = new File("D:\\cat");
if (!dir.exists()) {
dir.mkdirs();
}
String fileName = "image_" + currentTime + ".jpg";
File outputFile = new File(dir, fileName);
BufferedImage bufferedImage = this.convertImageInfoToBufferedImage(imageInfo);
if (bufferedImage != null) {
ImageIO.write(bufferedImage, "jpg", outputFile);
log.info("图片已保存:{}", outputFile.getAbsolutePath());
String filePath = RuoYiConfig.getUploadPath();
MultipartFile multipartFile = new CustomMultipartFile(outputFile, "image/jpeg");
String file_name = FileUploadUtils.upload(filePath, multipartFile);
VImage vImage = new VImage();
vImage.setImageName(file_name);
vImage.setImageData(fileName);
this.ivImageService.insertVImage(vImage);
} else {
log.error("无法转换 ImageInfo 为 BufferedImage");
}
} catch (IOException var16) {
log.error("保存图片失败:{}", var16.getMessage(), var16);
}
}
/**
* 将ImageInfo转换为BufferedImage
* @param imageInfo
* @return
*/
private BufferedImage convertImageInfoToBufferedImage(ImageInfo imageInfo) {
if (imageInfo != null && imageInfo.getImageData() != null) {
if (imageInfo.getImageFormat() == ImageFormat.CP_PAF_BGR24) {
int width = imageInfo.getWidth();
int height = imageInfo.getHeight();
byte[] imageData = imageInfo.getImageData();
BufferedImage bufferedImage = new BufferedImage(width, height, 5);
byte[] targetPixels = ((DataBufferByte)bufferedImage.getRaster().getDataBuffer()).getData();
System.arraycopy(imageData, 0, targetPixels, 0, imageData.length);
return bufferedImage;
} else {
log.error("不支持的图像格式:{}", imageInfo.getImageFormat());
return null;
}
} else {
log.error("ImageInfo 或 imageData 为空");
return null;
}
}
}

View File

@@ -0,0 +1,131 @@
package com.ruoyi.video.utils;
import org.springframework.web.multipart.MultipartFile;
import java.io.*;
/**
* File包装成MultipartFile
* @Author: orange
* @CreateTime: 2025-01-16
*/
public class CustomMultipartFile implements MultipartFile {
// 定义一个File类型的变量file
private final File file;
// 定义一个String类型的变量contentType
private final String contentType;
// 构造方法传入一个File类型的变量file和一个String类型的变量contentType
public CustomMultipartFile(File file, String contentType) {
this.file = file;
this.contentType = contentType;
}
// 获取文件名
public String getName() {
return this.file.getName();
}
// 获取原始文件名
public String getOriginalFilename() {
return this.file.getName();
}
// 获取文件类型
public String getContentType() {
return this.contentType;
}
// 判断文件是否为空
public boolean isEmpty() {
return this.file.length() == 0L;
}
// 获取文件大小
public long getSize() {
return this.file.length();
}
// 获取文件的字节数组
public byte[] getBytes() throws IOException {
// 创建一个FileInputStream对象传入file
InputStream inputStream = new FileInputStream(this.file);
byte[] var2;
try {
// 读取所有字节数组
var2 = inputStream.readAllBytes();
} catch (Throwable var5) {
try {
// 关闭输入流
inputStream.close();
} catch (Throwable var4) {
var5.addSuppressed(var4);
}
throw var5;
}
// 关闭输入流
inputStream.close();
return var2;
}
// 获取文件的输入流
public InputStream getInputStream() throws IOException {
return new FileInputStream(this.file);
}
// 将文件传输到指定的文件
public void transferTo(File dest) throws IOException, IllegalStateException {
// 如果目标文件不存在,则创建
if (!dest.exists()) {
dest.createNewFile();
}
// 创建一个FileInputStream对象传入file
InputStream inputStream = new FileInputStream(this.file);
try {
// 创建一个FileOutputStream对象传入dest
OutputStream outputStream = new FileOutputStream(dest);
try {
// 创建一个字节数组大小为1024
byte[] buffer = new byte[1024];
int bytesRead;
// 循环读取文件,直到读取完毕
while((bytesRead = inputStream.read(buffer)) != -1) {
// 将读取的字节数组写入目标文件
outputStream.write(buffer, 0, bytesRead);
}
} catch (Throwable var8) {
try {
// 关闭输出流
outputStream.close();
} catch (Throwable var7) {
var8.addSuppressed(var7);
}
throw var8;
}
// 关闭输出流
outputStream.close();
} catch (Throwable var9) {
try {
// 关闭输入流
inputStream.close();
} catch (Throwable var6) {
var9.addSuppressed(var6);
}
throw var9;
}
// 关闭输入流
inputStream.close();
}
}