修复ROI截图:改用ZLM内部RTSP地址,解决ffmpeg无法直连摄像头源的问题

后端改用app/stream参数构建ZLM内部地址(rtsp://127.0.0.1:{port}/{app}/{stream}),
避免ffmpeg从Docker内直接连接摄像头RTSP源。增加47255字节默认logo检测。
前端同步更新snap URL构建方式。

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-04 09:59:00 +08:00
parent 682b56a2ec
commit 88da798cc0
3 changed files with 32 additions and 18 deletions

View File

@@ -91,14 +91,23 @@ public class AiRoiController {
@Operation(summary = "获取摄像头截图") @Operation(summary = "获取摄像头截图")
@GetMapping("/snap") @GetMapping("/snap")
public void getSnap(HttpServletResponse resp, public void getSnap(HttpServletResponse resp,
@RequestParam String url) { @RequestParam String app,
@RequestParam String stream) {
MediaServer mediaServer = mediaServerService.getDefaultMediaServer(); MediaServer mediaServer = mediaServerService.getDefaultMediaServer();
if (mediaServer == null) { if (mediaServer == null) {
log.warn("[AI截图] 无可用媒体服务器"); log.warn("[AI截图] 无可用媒体服务器");
resp.setStatus(HttpServletResponse.SC_SERVICE_UNAVAILABLE); resp.setStatus(HttpServletResponse.SC_SERVICE_UNAVAILABLE);
return; return;
} }
// 直接调用ZLM的getSnap同步接口传入RTSP地址 // 使用ZLM内部RTSP地址截图(流必须在线)
String internalUrl;
if (mediaServer.getRtspPort() != 0) {
internalUrl = String.format("rtsp://127.0.0.1:%s/%s/%s", mediaServer.getRtspPort(), app, stream);
} else {
internalUrl = String.format("http://127.0.0.1:%s/%s/%s.live.mp4", mediaServer.getHttpPort(), app, stream);
}
log.info("[AI截图] app={}, stream={}, 内部地址={}", app, stream, internalUrl);
String zlmApi = String.format("http://%s:%s/index/api/getSnap", String zlmApi = String.format("http://%s:%s/index/api/getSnap",
mediaServer.getIp(), mediaServer.getHttpPort()); mediaServer.getIp(), mediaServer.getHttpPort());
HttpUrl httpUrl = HttpUrl.parse(zlmApi); HttpUrl httpUrl = HttpUrl.parse(zlmApi);
@@ -108,14 +117,14 @@ public class AiRoiController {
} }
HttpUrl requestUrl = httpUrl.newBuilder() HttpUrl requestUrl = httpUrl.newBuilder()
.addQueryParameter("secret", mediaServer.getSecret()) .addQueryParameter("secret", mediaServer.getSecret())
.addQueryParameter("url", url) .addQueryParameter("url", internalUrl)
.addQueryParameter("timeout_sec", "15") .addQueryParameter("timeout_sec", "10")
.addQueryParameter("expire_sec", "1") .addQueryParameter("expire_sec", "1")
.build(); .build();
OkHttpClient client = new OkHttpClient.Builder() OkHttpClient client = new OkHttpClient.Builder()
.connectTimeout(5, TimeUnit.SECONDS) .connectTimeout(5, TimeUnit.SECONDS)
.readTimeout(20, TimeUnit.SECONDS) .readTimeout(15, TimeUnit.SECONDS)
.build(); .build();
try { try {
@@ -123,14 +132,14 @@ public class AiRoiController {
Response response = client.newCall(request).execute(); Response response = client.newCall(request).execute();
if (response.isSuccessful() && response.body() != null) { if (response.isSuccessful() && response.body() != null) {
String contentType = response.header("Content-Type", ""); String contentType = response.header("Content-Type", "");
if (contentType.contains("image")) { byte[] bytes = response.body().bytes();
// ZLM默认logo是47255字节跳过它
if (contentType.contains("image") && bytes.length != 47255) {
resp.setContentType(MediaType.IMAGE_JPEG_VALUE); resp.setContentType(MediaType.IMAGE_JPEG_VALUE);
byte[] bytes = response.body().bytes();
resp.getOutputStream().write(bytes); resp.getOutputStream().write(bytes);
resp.getOutputStream().flush(); resp.getOutputStream().flush();
} else { } else {
// ZLM返回的是JSON错误信息不是图片 log.warn("[AI截图] 截图返回默认logo或非图片流可能未在线size={}", bytes.length);
log.warn("[AI截图] ZLM未返回图片可能流未在线: {}", response.body().string());
resp.setStatus(HttpServletResponse.SC_NO_CONTENT); resp.setStatus(HttpServletResponse.SC_NO_CONTENT);
} }
} else { } else {

View File

@@ -25,6 +25,7 @@ export function stopCamera(id) {
}) })
} }
export function getSnapUrl(srcUrl) { export function getSnapUrl(app, stream) {
return `/api/ai/roi/snap?url=${encodeURIComponent(srcUrl)}` const base = process.env.NODE_ENV === 'development' ? process.env.VUE_APP_BASE_API : ''
return `${base}/api/ai/roi/snap?app=${encodeURIComponent(app)}&stream=${encodeURIComponent(stream)}`
} }

View File

@@ -97,6 +97,8 @@ export default {
return { return {
cameraId: '', cameraId: '',
srcUrl: '', srcUrl: '',
app: '',
stream: '',
drawMode: null, drawMode: null,
roiList: [], roiList: [],
selectedRoiId: null, selectedRoiId: null,
@@ -113,10 +115,9 @@ export default {
mounted() { mounted() {
this.cameraId = decodeURIComponent(this.$route.params.cameraId) this.cameraId = decodeURIComponent(this.$route.params.cameraId)
this.srcUrl = this.$route.query.srcUrl || '' this.srcUrl = this.$route.query.srcUrl || ''
if (this.srcUrl) { this.app = this.$route.query.app || ''
const base = process.env.NODE_ENV === 'development' ? process.env.VUE_APP_BASE_API : '' this.stream = this.$route.query.stream || ''
this.snapUrl = `${base}/api/ai/roi/snap?url=${encodeURIComponent(this.srcUrl)}` this.buildSnapUrl()
}
this.loadRois() this.loadRois()
}, },
methods: { methods: {
@@ -145,12 +146,15 @@ export default {
startDraw(mode) { startDraw(mode) {
this.drawMode = mode this.drawMode = mode
}, },
refreshSnap() { buildSnapUrl() {
if (this.srcUrl) { if (this.app && this.stream) {
const base = process.env.NODE_ENV === 'development' ? process.env.VUE_APP_BASE_API : '' const base = process.env.NODE_ENV === 'development' ? process.env.VUE_APP_BASE_API : ''
this.snapUrl = `${base}/api/ai/roi/snap?url=${encodeURIComponent(this.srcUrl)}&t=${Date.now()}` this.snapUrl = `${base}/api/ai/roi/snap?app=${encodeURIComponent(this.app)}&stream=${encodeURIComponent(this.stream)}&t=${Date.now()}`
} }
}, },
refreshSnap() {
this.buildSnapUrl()
},
onRoiDrawn(data) { onRoiDrawn(data) {
this.drawMode = null this.drawMode = null
const newRoi = { const newRoi = {