HLS+FFMPEG实时视频流
在本地网络中接入多路大华摄像头时,如果想在浏览器中低成本播放实时画面,一个简单可行的方案是使用 FFmpeg 将 RTSP 转码为 HLS(m3u8)格式,再通过 Spring Boot 提供静态资源映射。
本文分享我在项目中搭建的完整方案:
- 不依赖 Nginx 或 Wowza;
- 支持 20 路摄像头同时推流;
- 自动守护、掉线重启;
- 浏览器原生可播放(HTML
<video>即可)。
一、方案原理
大华、海康等摄像头通常输出 RTSP 流,浏览器无法直接播放。
HLS(HTTP Live Streaming)是苹果提出的基于 HTTP 的流式传输协议,核心思想是:
- FFmpeg 从摄像头拉取 RTSP 码流;
- 切割为一系列小的
.ts文件; - 生成索引文件
.m3u8; - 浏览器通过 HTTP 轮询下载播放。
工作流程示意:
摄像头 → RTSP → FFmpeg → HLS片段(.ts) → index.m3u8 → 浏览器优点:
- 实现简单,浏览器原生支持;
- 通过 HTTP 传输,容易跨域、兼容 CDN;
- 可回放或做录像功能。
缺点:
- 延迟较高(通常 2~5 秒);
- 对实时性要求高的场景(如监控控制)不太合适;
- 需要持续磁盘写入(碎片化 I/O 较多)。
安装ffmpeg的build版本
https://ffmpeg.org/download.html
进入bin目录下,记住运行目录,设置环境变量: 
命令行输入
ffmpeg -version
运行成功后,会看到如下信息:

后续即可直接使用ffmpeg
二、Spring Boot 静态映射配置
我们将 FFmpeg 输出的 HLS 文件存放在 ./hls-stream 目录下,然后通过 Spring Boot 的 WebMvcConfigurer 将其映射为 /hls/** 访问路径。
@Configuration(proxyBeanMethods = false)
public class DahuaStaticMappingConfig implements WebMvcConfigurer {
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
String userDir = System.getProperty("user.dir");
Path hlsDir = Paths.get(userDir, "hls-stream").toAbsolutePath();
String fileLocation = "file:" + hlsDir + "/";
registry.addResourceHandler("/hls/**")
.addResourceLocations(fileLocation)
.setCachePeriod(0); // 禁止缓存,确保实时性
}
}这样,hls-stream/ip/channel1/index.m3u8
就能通过 http://localhost:8080/hls/ip/channel1/index.m3u8 访问。
三、批量 FFmpeg 管理器(DahuaStreamManager)
这部分核心是自动启动、监控、重启所有 FFmpeg 拉流任务。
每个摄像头的两路码流(主码流 + 子码流)分别运行一个独立的 FFmpeg 进程:
@Component
public class DahuaStreamManager {
private static final String USER = "xxx";
private static final String PASS = "xxxxx";
private static final int SUBTYPE = 0; // 0主码流 1子码流
private static final List<String> IPS = Arrays.asList(
"IP"
);
private final Map<String, DahuaStreamProcess> processes = new ConcurrentHashMap<>();
private ScheduledExecutorService watchdog;
@PostConstruct
public void startAll() {
String userDir = System.getProperty("user.dir");
File hlsRoot = new File(userDir, "hls-stream");
for (String ip : IPS) {
for (int ch = 1; ch <= 2; ch++) {
String name = ip + "-ch" + ch;
String rtsp = String.format("rtsp://%s:%s@%s:554/cam/realmonitor?channel=%d&subtype=%d", USER, PASS, ip, ch, SUBTYPE);
File outDir = new File(new File(hlsRoot, ip), "channel" + ch);
processes.put(name, new DahuaStreamProcess(name, rtsp, outDir, 2, 3));
}
}
// 启动所有进程
processes.values().forEach(proc -> {
try { proc.start(); } catch (Exception e) { e.printStackTrace(); }
});
// 看门狗:每10秒检查异常进程并重启
watchdog = Executors.newSingleThreadScheduledExecutor();
watchdog.scheduleAtFixedRate(this::checkAndHeal, 10, 10, TimeUnit.SECONDS);
}
private void checkAndHeal() {
processes.forEach((name, proc) -> {
if (!proc.isAlive()) {
try {
proc.restart();
} catch (Exception e) {
System.err.println("Restart failed: " + name);
}
}
});
}
@PreDestroy
public void stopAll() {
if (watchdog != null) watchdog.shutdownNow();
processes.values().forEach(DahuaStreamProcess::stop);
}
}四、FFmpeg 调用封装(DahuaStreamProcess)
每一路摄像头使用一个独立的 FFmpeg 命令:
ffmpeg -rtsp_transport tcp -i rtsp://admin:pass@ip:554/cam/realmonitor?channel=1&subtype=0 \
-fflags +genpts -c:v copy -an -f hls -hls_time 2 -hls_list_size 3 \
-hls_flags delete_segments+independent_segments ./hls-stream/ip/channel1/index.m3u8关键参数说明:
| 参数 | 说明 |
|---|---|
-rtsp_transport tcp | 使用 TCP 拉流,更稳定(UDP 容易丢包) |
-c:v copy | 不转码,直接拷贝视频码流(无 CPU 压力) |
-an | 不保留音频 |
-f hls | 输出格式为 HLS |
-hls_time 2 | 每个片段长度 2 秒 |
-hls_list_size 3 | 播放列表中保留 3 个片段 |
-hls_flags delete_segments+independent_segments | 自动删除旧片段,保证独立关键帧切割 |
这些参数组合可以将延迟控制在 3~6 秒之间,且磁盘空间占用较低。
五、浏览器播放
浏览器可直接使用 <video> 标签播放:
<video
src="http://localhost:8080/hls/IP/channel1/index.m3u8"
controls
autoplay
muted
playsinline>
</video>
<script src="https://cdn.jsdelivr.net/npm/hls.js@latest"></script>
<script>
const video = document.querySelector('video');
if (Hls.isSupported()) {
const hls = new Hls();
hls.loadSource(video.src);
hls.attachMedia(video);
}
</script>详细代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>大华相机 20 路 HLS 预览</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<!-- hls.js CDN -->
<script src="https://cdn.jsdelivr.net/npm/hls.js@latest"></script>
<style>
body { margin: 0; font-family: system-ui, sans-serif; background: #111; color: #eee; }
header { padding: 10px 16px; background: #1b1b1b; position: sticky; top: 0; z-index: 10; }
.grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
grid-auto-rows: 180px;
gap: 8px;
padding: 10px;
}
.card {
position: relative; background: #000; border-radius: 8px; overflow: hidden; border: 1px solid #333;
}
.card video { width: 100%; height: 100%; object-fit: cover; background: #000; }
.label {
position: absolute; left: 8px; bottom: 8px; padding: 4px 6px;
background: rgba(0,0,0,0.5); border-radius: 4px; font-size: 12px;
}
.card:hover { outline: 2px solid #4caf50; cursor: pointer; }
.modal {
position: fixed; inset: 0; background: rgba(0,0,0,.6); display: none; align-items: center; justify-content: center;
}
.modal .inner { width: 80vw; height: 60vh; background: #000; border-radius: 8px; overflow: hidden; border: 1px solid #444; position: relative; }
.modal .inner video { width: 100%; height: 100%; object-fit: contain; background: #000; }
.close { position: absolute; top: 8px; right: 8px; background: #222; color: #eee; border: 1px solid #555; padding: 6px 10px; border-radius: 4px; cursor: pointer; }
.tips { font-size: 12px; opacity: .8; }
</style>
</head>
<body>
<header>
<div>大华相机 HLS 预览(20 路,点击单路放大)</div>
<div class="tips">需要服务端 ffmpeg 在 PATH 中可执行;流地址格式:/hls/<IP>/channel{1|2}/index.m3u8</div>
</header>
<div class="grid" id="grid"></div>
<div class="modal" id="modal">
<div class="inner">
<button class="close" id="closeBtn">关闭</button>
<video id="modalVideo" controls playsinline></video>
</div>
</div>
<script>
const ips = [
"xxx"
];
const channels = [1, 2];
const BASE_URL = "http://localhost:80"; // 后端地址
const streams = [];
for (const ip of ips) {
for (const ch of channels) {
streams.push({
label: ip + " / ch" + ch,
url: BASE_URL + "/hls/" + ip + "/channel" + ch + "/index.m3u8"
});
}
}
const grid = document.getElementById('grid');
function createCard(stream) {
const card = document.createElement('div');
card.className = 'card';
const video = document.createElement('video');
video.muted = true; // 多路自动播放需要静音
video.autoplay = true;
video.playsInline = true;
video.controls = false;
const label = document.createElement('div');
label.className = 'label';
label.textContent = stream.label;
card.appendChild(video);
card.appendChild(label);
// 初始化 hls 播放
function attachHls(videoEl, url) {
if (videoEl.canPlayType('application/vnd.apple.mpegurl')) {
videoEl.src = url;
videoEl.play().catch(()=>{});
return { destroy(){} };
}
if (Hls.isSupported()) {
const hls = new Hls({ liveDurationInfinity: true, lowLatencyMode: true });
hls.loadSource(url);
hls.attachMedia(videoEl);
hls.on(Hls.Events.ERROR, (ev, data) => {
if (data.fatal) {
switch (data.type) {
case Hls.ErrorTypes.NETWORK_ERROR: hls.startLoad(); break;
case Hls.ErrorTypes.MEDIA_ERROR: hls.recoverMediaError(); break;
default: hls.destroy(); attachHls(videoEl, url);
}
}
});
return hls;
} else {
console.warn('HLS not supported in this browser');
return { destroy(){} };
}
}
const hls = attachHls(video, stream.url);
// 点击放大
card.addEventListener('click', () => openModal(stream.url, stream.label));
// 简单的可见性处理:不可见时暂停(可按需启用)
const io = new IntersectionObserver(entries => {
entries.forEach(e => {
if (e.isIntersecting) {
video.play().catch(()=>{});
} else {
video.pause();
}
});
}, { threshold: 0.2 });
io.observe(card);
return card;
}
streams.forEach(s => grid.appendChild(createCard(s)));
// 放大播放
const modal = document.getElementById('modal');
const modalVideo = document.getElementById('modalVideo');
const closeBtn = document.getElementById('closeBtn');
let modalHls = null;
function openModal(url, label) {
modal.style.display = 'flex';
if (modalVideo.canPlayType('application/vnd.apple.mpegurl')) {
modalVideo.src = url;
modalVideo.play().catch(()=>{});
} else if (Hls.isSupported()) {
modalHls = new Hls({ liveDurationInfinity: true, lowLatencyMode: true });
modalHls.loadSource(url);
modalHls.attachMedia(modalVideo);
modalHls.on(Hls.Events.MANIFEST_PARSED, () => modalVideo.play().catch(()=>{}));
}
}
function closeModal() {
modal.style.display = 'none';
modalVideo.pause();
modalVideo.removeAttribute('src');
if (modalHls) { modalHls.destroy(); modalHls = null; }
}
closeBtn.addEventListener('click', closeModal);
modal.addEventListener('click', (e) => { if (e.target === modal) closeModal(); });
</script>
</body>
</html>六、HLS 与 WebRTC 的对比
| 特性 | HLS | WebRTC |
|---|---|---|
| 延迟 | 3~6 秒 | < 1 秒 |
| 编解码 | 可拷贝或转码 | 通常需实时编解码 |
| 浏览器支持 | 通过 hls.js 实现 | 原生支持 |
| 网络兼容性 | HTTP,穿透简单 | 需 STUN/TURN |
| 适合场景 | 监控回放、直播、预览 | 实时交互、对讲、控制 |
| CPU 占用 | 低(复制模式) | 较高(实时编码) |
结论:
- 若主要用于视频预览、非强实时场景(如施工监控、设备可视化),HLS 简单稳定;
- 若需低延迟或双向互动,推荐 WebRTC。
七、总结
本方案通过:
- FFmpeg 做 RTSP → HLS 转换;
- Spring Boot 提供静态映射;
- Java 管理多进程与自动重启;
实现了一个稳定、高度自动化的多路摄像头流媒体转发系统。
它不依赖外部推流服务器,仅需 FFmpeg 与少量 Java 代码即可运行。
贡献者
flycodeu
版权所有
版权归属:flycodeu
