摄像头视频预览实现教程:从原理到落地(ZLMediaKit+ flv.js+ Spring Boot)
适用场景:把"摄像头的 RTSP 流"在浏览器里实时预览(无插件、低延迟)。
技术栈:ZLMediaKit(流媒体代理)+ flv.js(前端播放)+ Spring Boot(后端桥接)。
一、核心知识点(先搞清楚"为什么")
1.1 浏览器为什么不能直接播摄像头
摄像头(海康 / 大华 / 宇视等)输出的是 RTSP 流;而浏览器原生支持的是 HTTP / HTTPS / WebSocket,并不支持 RTSP 协议,也没有内置的 RTSP 解码器。因此必须有一层"服务端协议转换":
摄像头 RTSP ──(服务端转封装)──▶ 浏览器能播的格式(HTTP-FLV/HLS/WebRTC)
关键点:转的是"封装/协议",不是"画面内容"。只要流本身是 H.264/H.265,服务器只做重新打包,几乎不消耗 CPU(转封装 vs 转码的区别)。
1.2 三种浏览器播放方案对比
| 方案 | 延迟 | 浏览器兼容 | 部署复杂度 | 适用 |
|---|---|---|---|---|
| HTTP-FLV(本文方案) | 1~3s | Chrome/Edge/Firefox(需 flv.js) | 低(单 exe) | 监控大屏、常规预览 |
| HLS | 5~10s | 全平台原生 | 低 | 直播回放、对延迟不敏感 |
| WebRTC | <1s | 现代浏览器 | 中(需 NAT/ICE) | 实时联动、低延迟场景 |
结论:监控预览优先 HTTP-FLV;实时联动(如跟车随动)再升级 WebRTC,二者可共用同一套 ZLMediaKit。
1.3 为什么后端要"参与",而不是前端直连 ZLMediaKit
- 鉴权:flv 播放地址若直出,容易被盗链;由后端按权限发地址 + token 更可控。
- 解耦:摄像头 RTSP 地址 / 账号密码只存后端,不下发前端。
- 按需拉流控制:打开预览才建流,关闭才释放,节省摄像头连接数与带宽。
- 后端职责很轻:只"通知流媒体服务器拉流" + "返回播放地址",不参与任何转码。
1.4 关键概念速查
- 拉流代理(addStreamProxy):让 ZLMediaKit 主动去拉一个外部 RTSP/RTMP 源,并对外提供播放地址。
- HTTP-FLV:基于 HTTP 长连接的 FLV 流,flv.js 边下边播,适合直播。
- 按需拉流 :无人观看时自动断流(
streamNoneReaderDelayMS控制等待时长)。 - 流名(stream):每路视频的唯一 ID,建议用业务主键(如设备 ID)保证唯一。
二、架构设计
2.1 整体架构图
┌──────────────┐ RTSP拉流 ┌──────────────────────┐ HTTP-FLV ┌──────────────┐
│ 摄像头 / 视频源│ ───────────→ │ ZLMediaKit(流媒体服务) │ ─────────→ │ 浏览器 flv.js │
│ rtsp://... │ │ MediaServer │ │ 实时预览 │
└──────────────┘ │ 拉流代理 addStreamProxy│ └──────────────┘
└──────────┬───────────┘
│ HTTP API(被调用)
┌──────────┴───────────┐
│ 后端(Spring Boot) │
│ ① 接收前端"开播"请求 │
│ ② 调 ZLM 建流/关流 │
│ ③ 返回 flv 播放地址 │
└────────────────────────┘
2.2 分层职责
| 层 | 职责 | 不做什么 |
|---|---|---|
| 摄像头 / 视频源 | 产出 RTSP 流 | 不管浏览器、不管转协议 |
| ZLMediaKit | 拉 RTSP → 转 HTTP-FLV;提供管理 API | 不碰业务、不鉴权业务用户 |
| 后端 | 接收前端请求 → 调 ZLM API 建/关流 → 返地址 | 不转码、不存视频 |
| 前端 | 拿地址用 flv.js 播放;关闭时通知释放 | 不直接持有 RTSP/密钥 |
2.3 关键设计决策
- 后端只建流不转码:转封装由 ZLMediaKit 承担,Java 无计算压力。
- host 与 play-host 分离 (重要):
host:后端调 ZLM API 用的地址(内网 IP)。play-host:返回给浏览器的播放地址(跨网 / 域名访问时填公网可达地址)。- 二者可不同,解决"后端在内网、浏览器走域名"的网络差异。
- 按需拉流 + 显式释放 :打开预览建流,关闭弹窗显式
closeStream;同时依赖streamNoneReaderDelayMS兜底(无人观看自动断流)。 - 幂等建流 :流已存在时
addStreamProxy返回非 0 仍可忽略,照常返回播放地址,避免重复点击报错。 - 子码流优先 :多路同屏用子码流(如海康
102),单画面放大再切主码流(101),降低带宽与服务器压力。
三、实现流程(端到端步骤)
| 步骤 | 动作 | 产物 |
|---|---|---|
| 1 | 部署流媒体服务器 ZLMediaKit(Windows 单 exe / Linux 进程) | 可在 :80/index/api/getServerConfig 拿到 JSON |
| 2 | 准备视频源 RTSP 地址(真实摄像头 或 本地模拟) | 一个可被拉取的 RTSP URL |
| 3 | 后端:配 zlm.* + 写建流服务 StreamProxyService + 暴露 Controller |
open/close 两个接口 |
| 4 | 前端:引入 flv.js + 写通用播放组件 VideoPlayer |
传入 URL 即可播放 |
| 5 | 联调:前端拿地址 → flv.js 播放;关闭 → 释放 | 浏览器看到实时画面 |
四、通用示例代码
4.1 后端:配置属性(通用)
java
// ZlmProperties.java ------ 绑定 application.yml 的 zlm 节点
@Data
@Component
@ConfigurationProperties(prefix = "zlm")
public class ZlmProperties {
private String host = "127.0.0.1"; // 后端调 API 用的地址
private int httpPort = 80; // 与 ZLM config.ini [http].port 一致
private String playHost = "127.0.0.1"; // 返回给浏览器的播放地址
private String secret = ""; // 与 ZLM [api].secret 一致
private String app = "live"; // 流应用名
}
yaml
# application.yml
zlm:
host: 127.0.0.1
http-port: 80 # 【本项实际值】本地模拟常改 8080 避开 IIS
play-host: 127.0.0.1
secret: "035c73f7-bb6b-4889-a715-d9eb2d1925cc" # 【本项实际值】务必改成你自己的强随机串
app: live
java
// RestTemplateConfig.java
@Configuration
public class RestTemplateConfig {
@Bean
public RestTemplate restTemplate() {
return new RestTemplate();
}
}
4.2 后端:通用拉流服务
java
// StreamProxyService.java ------ 通用:给定 streamId + rtspUrl,返回 HTTP-FLV 地址
@Service
public class StreamProxyService {
private static final Logger log = LoggerFactory.getLogger(StreamProxyService.class);
@Autowired
private ZlmProperties zlm;
@Autowired
private RestTemplate restTemplate;
/** 打开一路流(幂等):拉起 RTSP 代理并返回 flv 地址 */
public String openStream(String streamId, String rtspUrl) {
try {
URI uri = UriComponentsBuilder
.fromHttpUrl(apiBase() + "/index/api/addStreamProxy")
.queryParam("secret", zlm.getSecret())
.queryParam("vhost", "__defaultVhost__")
.queryParam("app", zlm.getApp())
.queryParam("stream", streamId)
.queryParam("url", rtspUrl)
.queryParam("rtp_type", 0) // 0=TCP拉流(抗丢包,推荐)
.build().encode().toUri();
restTemplate.postForEntity(uri, null, String.class);
} catch (Exception e) {
// 流已存在/临时失败均不影响返回地址(幂等)
log.warn("addStreamProxy 异常:{}", e.getMessage());
}
return flvUrl(streamId);
}
/** 关闭一路流,释放资源。streamKey 格式为 app/stream(不含 .live) */
public void closeStream(String streamId) {
String streamKey = zlm.getApp() + "/" + streamId;
try {
URI uri = UriComponentsBuilder
.fromHttpUrl(apiBase() + "/index/api/delStreamProxy")
.queryParam("secret", zlm.getSecret())
.queryParam("streamKey", streamKey)
.build().toUri();
restTemplate.getForEntity(uri, String.class);
} catch (Exception e) {
log.warn("delStreamProxy 异常:{}", e.getMessage());
}
}
private String apiBase() {
return "http://" + zlm.getHost() + ":" + zlm.getHttpPort();
}
private String flvUrl(String streamId) {
return "http://" + zlm.getPlayHost() + ":" + zlm.getHttpPort()
+ "/" + zlm.getApp() + "/" + streamId + ".flv";
}
}
注意
delStreamProxy的streamKey参数名:ZLMediaKit 文档有时写key,本封装用streamKey(与本项目后端一致)。两者命中其一即可,务必前后统一。
4.3 后端:通用 Controller(只收 streamId + rtspUrl)
java
// StreamController.java ------ 通用入口,业务系统自行加 @PreAuthorize 鉴权
@RestController
@RequestMapping("/api/stream")
public class StreamController {
@Autowired
private StreamProxyService streamProxyService;
/** 开播:返回浏览器可播放的 flv 地址 */
@GetMapping("/open")
public Map<String, Object> open(@RequestParam String streamId,
@RequestParam String rtspUrl) {
String flvUrl = streamProxyService.openStream(streamId, rtspUrl);
return Map.of("code", 0, "flvUrl", flvUrl);
}
/** 关播:释放流媒体资源 */
@GetMapping("/close")
public Map<String, Object> close(@RequestParam String streamId) {
streamProxyService.closeStream(streamId);
return Map.of("code", 0, "msg", "closed");
}
}
业务落地时,通常把
rtspUrl的来源从"前端参数"改为"后端按业务主键查数据库",避免把摄像头地址暴露给前端(更安全)。本文为通用演示才允许前端传。
4.4 前端:通用播放组件(业务无关,只吃一个 URL)
vue
<!-- VideoPlayer.vue ------ 传入 flvUrl 即可播放,关闭自动销毁 -->
<template>
<div class="video-wrap">
<video ref="videoEl" class="video-el" controls autoplay muted playsinline></video>
<div v-if="errorMsg" class="video-error">{{ errorMsg }}</div>
</div>
</template>
<script>
import flvjs from 'flv.js'
export default {
name: 'VideoPlayer',
props: {
flvUrl: { type: String, default: '' }
},
data() {
return { player: null, errorMsg: '', retry: 0, maxRetry: 2 }
},
watch: {
flvUrl: {
immediate: true,
handler(url) { if (url) this.$nextTick(() => this.play(url)) }
}
},
methods: {
play(url) {
if (!flvjs.isSupported()) {
this.errorMsg = '当前浏览器不支持 flv.js,请使用 Chrome / Edge / Firefox'
return
}
this.destroy()
const video = this.$refs.videoEl
const player = flvjs.createPlayer({ type: 'flv', isLive: true, url })
this.player = player
player.attachMediaElement(video)
player.load(); player.play()
player.on(flvjs.Events.ERROR, (t, d) => {
console.error('flv error', t, d)
if (this.retry < this.maxRetry) { this.retry++; this.$nextTick(() => this.play(url)) }
else this.errorMsg = '视频加载失败,请检查流媒体服务与视频源'
})
},
destroy() {
if (this.player) {
try { this.player.pause(); this.player.unload(); this.player.detachMediaElement(); this.player.destroy() } catch (e) {}
this.player = null
}
}
},
beforeDestroy() { this.destroy() }
}
</script>
<style scoped>
.video-wrap { position: relative; width: 100%; background: #000; }
.video-el { display: block; width: 100%; height: 500px; object-fit: contain; background: #000; }
.video-error { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; color: #fff; text-align: center; }
</style>
4.5 前端:调用示例(拿地址 → 传组件)
js
// api/stream.js
export function openStream(streamId, rtspUrl) {
return request({ url: '/api/stream/open', method: 'get', params: { streamId, rtspUrl } })
}
export function closeStream(streamId) {
return request({ url: '/api/stream/close', method: 'get', params: { streamId } })
}
vue
<!-- 业务页面里接入(示例:点按钮预览、关闭释放) -->
<template>
<div>
<el-button @click="preview">视频预览</el-button>
<VideoPlayer v-if="url" :flv-url="url" />
</div>
</template>
<script>
import VideoPlayer from '@/components/VideoPlayer'
import { openStream, closeStream } from '@/api/stream'
export default {
components: { VideoPlayer },
data() { return { url: '', id: 'cam_1' } },
methods: {
async preview() {
const res = await openStream(this.id, 'rtsp://user:pwd@192.168.1.64:554/Streaming/Channels/102')
this.url = res.flvUrl
}
},
beforeDestroy() { if (this.id) closeStream(this.id) }
}
</script>
4.6 海康 RTSP 地址格式(备忘)
rtsp://<用户>:<密码>@<IP>:554/Streaming/Channels/<通道><码流>
└ 01=主码流(高清) 02=子码流(流畅,多路预览推荐)
示例:rtsp://admin:hk123456@192.168.1.64:554/Streaming/Channels/102
测试效果:

五、模拟调试流程(无真实摄像头)
目标:在没有摄像头硬件时,用 FFmpeg(假摄像头推流)+ MediaMTX(RTSP 源服务器)+ ZLMediaKit(转 FLV) 把整条链路跑通。
5.1 准备工具
| 工具 | 作用 | 说明 |
|---|---|---|
| FFmpeg | 把本地 test.mp4 循环推成 RTSP,模拟摄像头 |
Windows 选 gyan.dev 构建,解压得 bin/ffmpeg.exe |
| MediaMTX(原 rtsp-simple-server) | 极简 RTSP 服务器,接收推流并对外提供 RTSP 源 | 下载单文件 mediamtx.exe |
| ZLMediaKit | 把 RTSP 转 HTTP-FLV | 解压得 MediaServer.exe + config.ini |
test.mp4 |
被循环推送的"画面" | 任意几秒短片,H264+AAC 最稳 |
建议统一目录:
D:\tools\
├─ ffmpeg\bin\ffmpeg.exe
├─ mediamtx\mediamtx.exe
└─ ZLMediaKit\MediaServer.exe + config.ini
5.2 配置(模拟用)
ini
# ZLMediaKit/config.ini 关键两项
[api]
secret=035c73f7-bb6b-4889-a715-d9eb2d1925cc # 与 application.yml 一致
[http]
port=8080 # 本地建议 8080,避开 Windows 80 端口(IIS)
yaml
# application.yml
zlm:
host: 127.0.0.1
http-port: 8080
play-host: 127.0.0.1
secret: "035c73f7-bb6b-4889-a715-d9eb2d1925cc"
app: live
MediaMTX 默认即可(RTSP 监听 8554 ,流名 cam1)。
5.3 一键启动 / 停止脚本
bat
:: start_sim.bat ------ 依次拉起 mediamtx → ZLMediaKit → ffmpeg 推流(按需改路径)
@echo off
set MEDIAMTX_EXE=D:\tools\mediamtx\mediamtx.exe
set ZLM_DIR=D:\tools\ZLMediaKit
set FFMPEG_EXE=D:\tools\ffmpeg\bin\ffmpeg.exe
set TEST_VIDEO=D:\tools\test.mp4
set RTSP_URL=rtsp://127.0.0.1:8554/cam1
start "mediamtx" cmd /k "%MEDIAMTX_EXE%"
timeout /t 3 /nobreak >nul
start "ZLMediaKit" cmd /k "cd /d %ZLM_DIR% && MediaServer.exe"
timeout /t 3 /nobreak >nul
start "ffmpeg-push" cmd /k "%FFMPEG_EXE% -re -stream_loop -1 -i %TEST_VIDEO% -c copy -f rtsp %RTSP_URL%"
bat
:: stop_sim.bat ------ 关闭三个服务
taskkill /fi "WINDOWTITLE eq mediamtx*" /f
taskkill /fi "WINDOWTITLE eq ZLMediaKit*" /f
taskkill /fi "WINDOWTITLE eq ffmpeg-push*" /f
FFmpeg 推流命令逐项:
bat
ffmpeg -re -stream_loop -1 -i test.mp4 -c copy -f rtsp rtsp://127.0.0.1:8554/cam1
# -re 按原始帧率读取(不加会瞬间推完)
# -stream_loop -1 无限循环(模拟 7x24 监控)
# -c copy 不转码直接拷贝(最省 CPU;失败改 -c:v libx264 -c:a aac)
# -f rtsp 强制 RTSP 封装
5.4 验证步骤
- 运行
start_sim.bat,确认三个窗口有日志输出(mediamtx 出现RTSP server listening on :8554,ZLMediaKit 监听 8080)。 - 把"视频源地址"设为
rtsp://127.0.0.1:8554/cam1(真实项目中对应设备表的 RTSP 字段)。 - 前端点"视频预览",预期看到
test.mp4循环画面 → 整条链路打通。 - 关闭弹窗:前端调用
closeStream,后端执行delStreamProxy释放。
不依赖前端的接口验证(分层排障用):
bat
:: 1) 拉起 RTSP 代理
curl "http://127.0.0.1:8080/index/api/addStreamProxy?secret=035c73f7-bb6b-4889-a715-d9eb2d1925cc&vhost=__defaultVhost__&app=live&stream=dev_1&url=rtsp://127.0.0.1:8554/cam1&rtp_type=0"
:: 2) 浏览器直接打开,应能看到(或下载到)持续 flv 流
:: http://127.0.0.1:8080/live/dev_1.flv
:: 3) 释放
curl "http://127.0.0.1:8080/index/api/delStreamProxy?secret=035c73f7-bb6b-4889-a715-d9eb2d1925cc&streamKey=live/dev_1"
5.5 分层排障法(定位问题在哪一层)
- 先确认源(最底层) :
ffprobe -rtsp_transport tcp rtsp://127.0.0.1:8554/cam1能否看到h264视频轨 → 验证 FFmpeg 推流 + MediaMTX。 - 再确认转换层 :ZLMediaKit 进程在不在、端口是否监听、
/index/api/getServerConfig是否返回 JSON → 验证 ZLMediaKit。 - 最后确认播放层 :浏览器 F12 看
*.flv请求是否 200、flv.js 是否报错 → 验证前端。 - 端口冲突优先查 80 (IIS)和 8554(多个 MediaMTX 实例)。
- secret 必须全链路一致 :
config.ini [api].secret=application.yml zlm.secret= 后端调 API 携带值。
六、常见问题速查
| 现象 | 原因 / 解决 |
|---|---|
| 前端黑屏一直加载 | ①F12 看 flv 请求是否 200 ②ZLM 是否真拉到 RTSP(看日志/getMediaList)③RTSP 账号密码/通道码流是否正确 |
| addStreamProxy 返回非 0 | 流已存在(幂等可忽略)或 RTSP 不可达;用 VLC 打开该 RTSP 验证摄像头侧 |
| 80 端口起不来 | 被 IIS/SVCHOST 占用 → 改 config.ini [http].port=8080,同步改 zlm.http-port |
| 延迟 >3s | 关 flv.js stashBuffer、用子码流、rtp_type=0;仍不够升级 WebRTC |
| 局域网能看外网看不到 | 摄像头/ZLM 在内网,需 Nginx 反代或端口映射 |
| 画面卡住/不循环 | 确认 FFmpeg 带 -stream_loop -1;test.mp4 可解码;-c copy 失败改转码 |
| "Protocol not found" | FFmpeg 构建不含 RTSP muxer,换 gyan.dev 完整构建 |
七、进阶与演进
- 升级 WebRTC(<1s 延迟) :同一套 ZLMediaKit,前端把
flv.js换成 WebRTC 拉流(ZLM/index/api/webrtc?app=live&stream=xxx&type=play),后端建流逻辑不变。适合实时联动(跟车随动、见尘即喷)。 - 播放鉴权 :开启 ZLM
on_playhook,后端校验一次性 token(用登录态换取),防止 flv 地址被盗链。 - 统一账号管理:摄像头 RTSP 地址里的账号密码集中存后端配置/加密,前端只传业务主键,更安全。
- 多路同屏 :大屏循环渲染多个
<VideoPlayer>,统一子码流、限制同屏 ≤ 4~9 路,避免带宽/CPU 打满。