摄像头视频预览实现教程:从原理到落地(ZLMediaKit+ flv.js+ Spring Boot)

摄像头视频预览实现教程:从原理到落地(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 关键设计决策

  1. 后端只建流不转码:转封装由 ZLMediaKit 承担,Java 无计算压力。
  2. host 与 play-host 分离 (重要):
    • host:后端调 ZLM API 用的地址(内网 IP)。
    • play-host:返回给浏览器的播放地址(跨网 / 域名访问时填公网可达地址)。
    • 二者可不同,解决"后端在内网、浏览器走域名"的网络差异。
  3. 按需拉流 + 显式释放 :打开预览建流,关闭弹窗显式 closeStream;同时依赖 streamNoneReaderDelayMS 兜底(无人观看自动断流)。
  4. 幂等建流 :流已存在时 addStreamProxy 返回非 0 仍可忽略,照常返回播放地址,避免重复点击报错。
  5. 子码流优先 :多路同屏用子码流(如海康 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";
    }
}

注意 delStreamProxystreamKey 参数名: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 验证步骤

  1. 运行 start_sim.bat,确认三个窗口有日志输出(mediamtx 出现 RTSP server listening on :8554,ZLMediaKit 监听 8080)。
  2. 把"视频源地址"设为 rtsp://127.0.0.1:8554/cam1(真实项目中对应设备表的 RTSP 字段)。
  3. 前端点"视频预览",预期看到 test.mp4 循环画面 → 整条链路打通。
  4. 关闭弹窗:前端调用 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 分层排障法(定位问题在哪一层)

  1. 先确认源(最底层)ffprobe -rtsp_transport tcp rtsp://127.0.0.1:8554/cam1 能否看到 h264 视频轨 → 验证 FFmpeg 推流 + MediaMTX。
  2. 再确认转换层 :ZLMediaKit 进程在不在、端口是否监听、/index/api/getServerConfig 是否返回 JSON → 验证 ZLMediaKit。
  3. 最后确认播放层 :浏览器 F12 看 *.flv 请求是否 200、flv.js 是否报错 → 验证前端。
  4. 端口冲突优先查 80 (IIS)和 8554(多个 MediaMTX 实例)。
  5. 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 -1test.mp4 可解码;-c copy 失败改转码
"Protocol not found" FFmpeg 构建不含 RTSP muxer,换 gyan.dev 完整构建

七、进阶与演进

  1. 升级 WebRTC(<1s 延迟) :同一套 ZLMediaKit,前端把 flv.js 换成 WebRTC 拉流(ZLM /index/api/webrtc?app=live&stream=xxx&type=play),后端建流逻辑不变。适合实时联动(跟车随动、见尘即喷)。
  2. 播放鉴权 :开启 ZLM on_play hook,后端校验一次性 token(用登录态换取),防止 flv 地址被盗链。
  3. 统一账号管理:摄像头 RTSP 地址里的账号密码集中存后端配置/加密,前端只传业务主键,更安全。
  4. 多路同屏 :大屏循环渲染多个 <VideoPlayer>,统一子码流、限制同屏 ≤ 4~9 路,避免带宽/CPU 打满。
相关推荐
double_eggm19 小时前
uniapp.3
开发语言·前端·javascript
Thanks_ks21 小时前
从零手写一个 Promise:深入理解 JavaScript 异步编程的本质
javascript·promise·事件循环·异步编程·手写源码·前端进阶·promises/a+
胡萝卜术1 天前
力扣5. 最长回文子串
前端·javascript·面试
触底反弹1 天前
🔥 从零搭建 RAG 知识库:爬虫→分词→向量化→检索,一步都不能错
javascript·人工智能·面试
宠友信息1 天前
消息序号如何保证即时通讯源码聊天记录稳定加载
java·spring boot·redis·python·mysql·uni-app
小柯南敲键盘1 天前
批量图片翻译与视频字幕一站式解决高效跨境电商沟通难题
大数据·人工智能·python·音视频
এ慕ོ冬℘゜1 天前
JavaScript 数组核心方法实战|新增元素 + 数组转字符串 零基础详解
开发语言·javascript·ecmascript
豆瓣鸡1 天前
XXL-JOB 定时任务——从 Docker 部署到分片广播,分布式任务调度落地
spring boot·分布式·docker·定时任务