特点
- 延迟低,延迟在1秒左右
- 嵌入简单,前端只需引用一个js
- 本项目直接放源码,不收费。
介绍
开发园区项目过程中,需要在web上播放监控摄像头的视频流,chrome并不能支持rtsp协议,所以有了这个项目。
软件架构
本项目是利用ffmpeg进行转码,将流转发到后台,然后后台通过websocket协议将流数据再转发到页面上的jsmpeg播放器。
这个方案是jsmpeg官方提供的样例代码的方案,不过它是利用node实现的,本项目采用作者比较熟悉的java来实现,并且添加了状态监控的页面。
项目启动
项目创建时采用了最新的java21和最新的springboot3.2.4,各位可以根据自己需要来修改。
项目启动后首页为http://localhost:8088/index.html,首页有状态信息,并且通过首页可以打开测试页。
仓库地址
https://gitee.com/wagio_admin/rtsp2web.git
核心代码
package cn.wagio.rtsp2web.websocket;
import jakarta.websocket.*;
import jakarta.websocket.server.ServerEndpoint;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import java.io.IOException;
import java.io.InputStream;
import java.nio.ByteBuffer;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Base64;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import static cn.wagio.rtsp2web.websocket.WebSocketCache.*;
@Component
@ServerEndpoint("/rtsp")
public class WebSocketServer {
private static final DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
/**
* 建立连接成功调用的方法
*/
@OnOpen
public void onOpen(Session session) {
String sessionId = session.getId();
try {
msgList.add("建立连接,会话id:" + sessionId);
Map<String, List<String>> parameterMap = session.getRequestParameterMap();
if (parameterMap.get("url") == null) {
session.getBasicRemote().sendText("缺失参数:url");
msgList.add(getTime() + "会话" + sessionId + "缺失参数:url");
return;
}
String url = new String(Base64.getDecoder().decode(String.join("", parameterMap.get("url"))));
if (!StringUtils.hasText(url)) {
session.getBasicRemote().sendText("参数url为空");
msgList.add("会话" + sessionId + "缺失参数:url");
return;
}
if (!url.startsWith("rtsp://")) {
session.getBasicRemote().sendText("仅支持rtsp协议视频流");
return;
}
Process process = processMap.get(url);
// 如果已有解析任务闲置,就直接用,不需要再调用ffmpeg去解析
if (process != null) {
// 检查待执行的任务列表中,有没有关闭这个进程的定时任务,有的话就取消
ScheduledFuture<?> future = scheduledMap.get(url);
if (future != null) {
boolean cancelled = future.cancel(true);
if (cancelled) {
msgList.add(getTime() + "解析任务【" + url + "】的定时关闭任务取消成功");
} else {
msgList.add(getTime() + "解析任务【" + url + "】的定时关闭任务取消失败");
process = getProcess(url);
}
scheduledMap.remove(url); // 删除此待执行任务
processMap.remove(url); // 删除此待执行任务
}
} else {
process = getProcess(url);
}
sessionMap.put(sessionId, new FfmpegProcess(url, process));
InputStream inputStream = process.getInputStream();
byte[] buffer = new byte[102400];
int bytesRead;
while (session.isOpen() && (bytesRead = inputStream.read(buffer)) != -1) {
ByteBuffer byteBuffer = ByteBuffer.wrap(buffer, 0, bytesRead);
try {
session.getBasicRemote().sendBinary(byteBuffer);
byteBuffer.clear();
} catch (Exception e) {
msgList.add(getTime() + "会话" + sessionId + "发送数据异常:" + e.getMessage());
}
}
} catch (Exception e) {
msgList.add(getTime() + "会话" + sessionId + "异常:" + e.getMessage());
}
}
private static Process getProcess(String url) throws IOException {
Process process;
// 构建命令
ProcessBuilder pb = new ProcessBuilder(
"ffmpeg",
// "-hwaccel", "opencl",
"-i", url,
"-f", "mpegts",
"-codec:v", "mpeg1video",
"-b:v", "1000k",
"-bf", "0",
"pipe:1"
);
// 启动 FFmpeg 进程
process = pb.redirectErrorStream(true).start();
return process;
}
/**
* 连接关闭调用的方法
*/
@OnClose
public void onClose(Session session) {
String sessionId = session.getId();
msgList.add(getTime() + "会话" + sessionId + "断开");
FfmpegProcess ffmpegProcess = sessionMap.get(sessionId);
if (ffmpegProcess == null) {
return;
}
sessionMap.remove(sessionId);
// 将进程放到闲置的进程中,20秒后将停止
ScheduledFuture<?> schedule = executorService.schedule(() -> {
Process process = processMap.get(ffmpegProcess.getUrl());
if (process != null && process.isAlive()) { // 如果进程仍在运行
process.destroy(); // 销毁进程
msgList.add(getTime() + "解析任务【" + ffmpegProcess.getUrl() + "】已停止");
}
processMap.remove(ffmpegProcess.getUrl()); // 清空待执行的任务列表
scheduledMap.remove(ffmpegProcess.getUrl()); // 清空待执行的任务列表
}, 20, TimeUnit.SECONDS);
processMap.put(ffmpegProcess.getUrl(), ffmpegProcess.getProcess());
scheduledMap.put(ffmpegProcess.getUrl(), schedule); // 将任务放入待执行列表
msgList.add(getTime() + "解析任务【" + ffmpegProcess.getUrl() + "】已放入定时任务,20秒后将停止");
}
/**
* 收到客户端消息后调用的方法
* @param message 客户端发送过来的消息
**/
@OnMessage
public void onMessage(String message, Session session) {
msgList.add(getTime() + "接收到会话" + session.getId() + "消息,报文:" + message);
}
@OnError
public void onError(Session session, Throwable error) {
msgList.add(getTime() + "会话" + session.getId() + "异常,原因:" + error.getLocalizedMessage());
}
private String getTime() {
return LocalDateTime.now().format(formatter) + " ";
}
}