简介:Easy-FLV是一个用Java实现的RTSP/RTMP转FLV流媒体转换库,面向需要将监控、直播等实时视频流在浏览器端直接播放的开发者。它借助Java跨平台能力与网络编程优势,解决了传统RTSP/RTMP无法被浏览器原生支持的问题,适合视频监控、网页直播、流媒体处理等多个应用场景,也可作为学习Java网络编程与流媒体协议转换的入门参考。压缩包仅1.18MB,共18个文件,主体是12个Java源文件,覆盖核心转换逻辑;同时附带HTML播放示例页面、PNG预览图、Maven配置文件和许可证说明,便于直接阅读、导入工程或二次开发。目前已有594人学习下载。对希望掌握流媒体协议转换原理、快速搭建浏览器播放链路的开发者而言,这份源码提供了可运行的实现与参考,能够帮助理解FLV封装、RTSP/RTMP拉流转码、Web端播放等关键环节。
1. 当浏览器遇到 RTSP/RTMP:Easy-FLV 的解法
监控大屏要直接预览海康摄像头,直播运营要求观众在网页上看到 RTMP 推流,这两个需求几乎是所有内容平台的标配。但浏览器原生只支持 HTTP 协议族,RTSP 的媒体流走 RTP,RTMP 则依赖 Flash 时代的 chunk 流机制,两边无法直接对话。Easy-FLV 是一个用 Java 实现的流媒体转换工具,它把 RTSP/RTMP 拉取下来、解封装成裸音视频帧,重新封装成 FLV,再通过 HTTP-FLV 输出,配合 flv.js 就能让浏览器无插件播放。它适合要自建流接入层的后端、做监控平台 Web 化的开发者,以及在直播链路里做协议适配的服务端同学。
2. Easy-FLV 的协议转换原理与 Java 实现选型
2.1 RTSP、RTMP 不能直接进浏览器的原因
RTSP(Real Time Streaming Protocol)自身只负责会话控制:客户端发 OPTIONS、DESCRIBE、SETUP、PLAY 四步请求,服务端返回 SDP 描述媒体参数,真正的音视频数据由 RTP 在 UDP 或 TCP 上传送。浏览器没有内置的 RTSP 会话状态机,也缺少对 SDP 和 RTP 载荷的统一解析;即使知道了流地址,video 标签也不知道去哪里取数据。RTMP(Real Time Messaging Protocol)基于 TCP 长连接,数据按 chunk 分块,前面还有 AMF 编码的头和握手,这套逻辑只被 Flash Player 完整实现过,而现代浏览器早就移除了 Flash 运行时。
FLV 是 Flash Video 的封装格式,结构上只有文件头、脚本 tag、视频 tag 和音频 tag。关键是,FLV 里存放的 H.264 视频和 AAC 音频编码数据,和 RTSP/RTMP 里传输的是同一种裸流,只是容器壳不同。浏览器里的 flv.js 通过 Media Source Extensions 把 FLV 解析成媒体片段喂给 video 标签,因此只要把输入源的 H.264/AAC 拆出来塞进 FLV tag,播放问题就解决了。Easy-FLV 的核心思路就是这个“解封装—重封装”过程,全程不碰像素转码,性能开销很小。
| 协议 | 传输层 | 控制面 | 浏览器原生支持 |
|---|---|---|---|
| RTSP | UDP/TCP | RTSP 会话 + SDP | 无 |
| RTMP | TCP | 自有握手 + AMF | 无 |
| FLV over HTTP | TCP | HTTP | 需 flv.js/MSE |
| HLS | TCP | HTTP + m3u8 | 有 |
选 HLS 也能做浏览器播放,但切片粒度决定延迟通常在 5 秒以上;HTTP-FLV 的延迟能控制在几百毫秒,更适合监控预览和互动直播。
2.2 转换管线的模块划分
构建这类转换工具,我一般把代码切成四块:源连接器负责和 RTSP/RTMP 服务器打交道,解封装器从容器中读出 H.264 NAL 单元和 AAC 帧,FLV 封装器负责把裸帧变成标准 tag,HTTP 输出端则面向浏览器维护长连接。模块之间通过一个包队列衔接,抓流线程按 DTS 顺序入队,每个订阅者从队列中取 tag 写出。断流时源连接器负责按退避算法重连,队列要能容忍短暂抖动但不能无限增长,否则延迟会持续膨胀。
| 模块 | 职责 | 核心方法 |
|---|---|---|
| 源连接器 | 建立 RTSP/RTMP 连接、协商参数、处理断线重连 | connect() / reconnect() |
| 解封装器 | 从容器中读出裸帧,维护时间戳 | nextPacket() |
| FLV 封装器 | 写 header/script tag/音视频 tag,归一化时间戳 | writeHeader() / writeTag() |
| HTTP 输出端 | 处理订阅请求、维护连接、控制背压 | publish() / close() |
模块化的好处是 RTSP 和 RTMP 两种输入可以共用后面的封装和输出逻辑。Easy-FLV 内部把两者抽象成同一个抓帧接口,所以你在接入时只需要改 URL 前缀和传输层参数,封装层完全不用动。
2.3 一段核心转换伪代码
// 伪代码:Easy-FLV 的核心转换循环 StreamSource source = sourceFactory.create(inputUrl); // 输入为 rtsp:// 或 rtmp:// FlvMuxer muxer = new FlvMuxer(); source.open(); muxer.writeHeader(); // 输出 9 字节 FLV header while (source.hasNextPacket()) { Packet p = source.nextPacket(); if (p.isVideo()) { muxer.writeTag(0x09, p.data, p.pts - baseTimestamp); // 视频 tag 类型 0x09 } else { muxer.writeTag(0x08, p.data, p.pts - baseTimestamp); // 音频 tag 类型 0x08 } }这段逻辑说明了转换链路的工作方式:writeHeader写入 9 字节 FLV header,紧跟一个 onMetaData 脚本 tag,flv.js 依赖它拿到宽高和码率信息。baseTimestamp取第一帧的 PTS,所有 tag 时间戳都做差值,否则时间轴不从 0 开始会导致播放器行为异常。p.pts对 RTSP 来自 RTP 时间戳,对 RTMP 来自 chunk 里的 timestamp 字段,单位都是毫秒,但要注意时钟漂移,尤其在多路转发的场景。
2.4 为什么选 Java 而不是 C/Go
流媒体网关最常见的实现语言是 C++,性能和底层控制最强,但业务集成成本高;Go 在并发模型上有优势,cgo 调 FFmpeg 却比较麻烦。Java 适合的场景是团队已有 Java 技术栈、需要快速和 Web 业务打通的场景。JVM 提供跨平台能力,同一个 jar 在 x86 和 ARM 服务器上都能跑;JavaCV 封装了 FFmpeg 的拉流和解封装能力,底层 native 库由它管理,省去自己编译 FFmpeg 的维护成本;HTTP 输出可以用 Netty 或内嵌 Tomcat,和既有监控平台共用一套基础设施。缺点也有:一路流大概多占用几十 MB 堆内存,大规模部署时要压 JVM 参数,后面会说到。
提示:RTSP 拉流优先选 TCP 传输。UDP 在跨网段时丢包会直接导致花屏和马赛克,而且问题很难复现。用
rtsp_transport=tcp让底层走 TCP 通道,代价是略增加延迟,但稳定性好得多。
3. 源码集成与 RTSP/RTMP 转 FLV 的代码实现
3.1 工程结构与依赖
easy-flv-main 是一个标准 Maven 工程,解压后目录里有 pom.xml、src/main、LICENSE、.gitignore。导入 IDE 后先确认 JDK 版本,这类项目一般要求 JDK 8 以上。pom.xml 里最关键的依赖是 JavaCV,它把 FFmpeg 的 native 能力接到 Java 上;还要有一个 HTTP 服务器,常见做法是引入 Netty。依赖摘录如下:
<properties> <javacv.version>1.5.9</javacv.version> </properties> <dependencies> <dependency> <groupId>org.bytedeco</groupId> <artifactId>javacv-platform</artifactId> <version>${javacv.version}</version> </dependency> <dependency> <groupId>io.netty</groupId> <artifactId>netty-all</artifactId> <version>4.1.100.Final</version> </dependency> </dependencies>javacv-platform 这个依赖会把 Windows/Linux 的 native 库都打进来,导致 jar 体积偏大,生产上可以用 javacv 加 javacv-ffmpeg 按平台裁剪。netty-all 只是为了提供 HTTP 流式响应,也可以换内嵌 Tomcat 或 Jetty,但 Netty 在长连接并发下更贴合流媒体场景。版本号按你实际编译环境调整,不要盲目追新,JavaCV 大版本更新经常伴随 API 变动。
3.2 抓流与输出的核心代码
public void convertToFlv(String inputUrl, OutputStream out) throws IOException { FFmpegFrameGrabber grabber = new FFmpegFrameGrabber(inputUrl); if (inputUrl.startsWith("rtsp://")) { grabber.setOption("rtsp_transport", "tcp"); } grabber.setOption("stimeout", "5000000"); grabber.setOption("analyzeduration", "5000000"); grabber.start(); // FLV header: 'F' 'L' 'V' 版本 1, 0x05 表示有音视频, header 长度 9 byte[] flvHeader = new byte[] { 0x46, 0x4C, 0x56, 0x01, 0x05, 0x00, 0x00, 0x00, 0x09 }; out.write(flvHeader); long baseTimestamp = -1; Frame frame; while ((frame = grabber.grab()) != null) { if (baseTimestamp < 0) { baseTimestamp = frame.timestamp / 1000; } byte tagType = (frame.image != null) ? 0x09 : 0x08; // 视频帧还是音频帧 byte[] tag = buildFlvTag(tagType, frame, baseTimestamp); out.write(tag); } grabber.stop(); }rtsp_transport=tcp只对 RTSP 源生效,RTMP 源不需要设置;stimeout是 5 秒没有数据就判定断流,单位是微秒,5000000 微秒等于 5 秒;analyzeduration控制打开流时分析媒体信息的时长,设太短会漏掉 SPS/PPS,导致浏览器端黑屏。buildFlvTag负责拼 11 字节的 tag 头和 DataSize/Timestamp 字段,实现时注意时间戳是 24 位小端加 8 位扩展,frame.timestamp单位是微秒,要先除以 1000 转成毫秒再写入。
3.3 参数配置对照
下面这些参数一部分是 FFmpeg 拉流 option,一部分是 Easy-FLV 输出端定义的策略,落地时看具体封装的暴露方式:
| 参数 | 作用 | 常见值 | 调大/调小的影响 |
|---|---|---|---|
| rtsp_transport | RTSP 传输层协议 | tcp | udp 延迟低但丢包易花屏 |
| stimeout | 拉流 socket 超时(微秒) | 5000000 | 太小会误判断流,太大故障恢复慢 |
| analyzeduration | 流分析时长(微秒) | 5000000 | 太小丢失关键参数;太大拉流变慢 |
| max_delay | 最大缓存延迟(微秒) | 500000 | 影响首帧和整体延迟,实时流适当减小 |
| gop_cache | 输出端是否缓存 GOP | 1 | 开启后秒开但增加内存占用 |
实时场景下我会把max_delay压到 500000 微秒以内,避免播放器端缓冲越来越厚;但也不能压到 0,网络抖动时时间戳收到乱序,完全没有缓冲会让音视频 tag 交错混乱。gop_cache放在输出端更合适,它决定新客户端接入时是否立刻拿到一个关键帧,这直接关系到首屏秒开,第 5 章会展开讲。
4. 实战:把一路 RTSP 监控流发布成 HTTP-FLV 地址
4.1 准备测试视频源
没有真实摄像头的时候,先在本地造一路流。最简单的方式是拿一个 mp4 文件用 ffmpeg 推到本地流媒体服务:
ffmpeg -re -i sample.mp4 -c copy -f flv rtmp://127.0.0.1:1935/live/demo这条命令按文件的原始时间戳匀速读取(-re),直接拷贝编码流(-c copy)到本地 RTMP 端口。前提是本地跑了一个支持 RTMP 的服务,比如 nginx-rtmp 或 mediamtx。没有服务的话,也可以用带 RTSP 输出的服务端程序,ffmpeg 本身不内置 RTSP server,需要先起一个独立进程接收。
如果接真实摄像头,地址格式通常是:
- 海康威视:
rtsp://用户名:密码@IP:554/Streaming/Channels/101,101 代表主码流第一通道 - 大华:
rtsp://用户名:密码@IP:554/cam/realmonitor?channel=1&subtype=0,subtype=0 主码流,subtype=1 子码流
调试阶段建议先用子码流,带宽和 CPU 占用低,验证完链路再切主码流;公网上的测试流大多不稳定,会影响对转换链路的判断。
4.2 启动转换并验证输出
编译打包:
mvn clean package -DskipTests启动时把输入源作为参数传入(以项目实际入口为准):
java -jar target/easy-flv.jar \ --source rtmp://127.0.0.1:1935/live/demo \ --http-port 8080启动后用 curl 验证:
curl -i --max-time 5 http://127.0.0.1:8080/live/demo.flv | head -20如果看到HTTP/1.1 200 OK、Content-Type: video/x-flv,且后续输出是二进制乱码,说明 HTTP-FLV 已经推出来了。再用 ffprobe 确认流的编码信息:
ffprobe -show_streams -select_streams v \ -show_entries stream=codec_name,width,height,avg_frame_rate \ http://127.0.0.1:8080/live/demo.flv这一步能直接看到是不是 h264、分辨率和帧率是否符合预期。如果 codec_name 是 h264 且 width 不为 0,链路基本没问题,可以进入前端播放环节。
4.3 浏览器端接入 flv.js
前端需要一个接收 FLV 的播放器,flv.js 是目前最常见的实现。工程里先放一份编译好的 flv.min.js,然后写播放器页面:
<script src="libs/flv.min.js"></script> <video id="monitor" muted controls autoplay></video> <script> if (flvjs.isSupported()) { var flvPlayer = flvjs.createPlayer({ type: 'flv', isLive: true, url: 'http://127.0.0.1:8080/live/demo.flv' }); flvPlayer.attachMediaElement(document.getElementById('monitor')); flvPlayer.load(); flvPlayer.play(); } </script>isLive: true告诉 flv.js 走实时流逻辑,不等待 duration 也不做 seek;muted是为了绕过浏览器自动播放策略,用户点击交互后再放开音量。flv.js 内部通过 fetch 或 stream 读取 FLV 数据,再用 MSE 转成浏览器能消费的媒体片段,所以 Easy-FLV 侧只要持续写 HTTP 响应体,客户端会自己消费。如果连播放器都不想用,也可以用 VLC 直接打开 HTTP-FLV 地址验证。
4.4 常见问题与排查顺序
排错先分方向:先排除源,再查转换链,最后看播放器。基顺序错了会浪费大量时间。
| 表现 | 排查方向 | 处理建议 |
|---|---|---|
| 页面一直黑屏 | 源没拉通 / 关键帧丢失 | 先用 ffplay 直接放源地址;开启 gop cache |
| 延迟越来越大 | 缓冲堆积 | 调小 max_delay,检查输出队列是否打满 |
| 画面花屏 | RTSP 走了 UDP / 丢包 | 强制 tcp 传输,抓包看 RTP 重传 |
| 音画不同步 | 时间戳未做起点归一化 | 检查 baseTimestamp 是否取了第一帧 |
| 客户端反复断连 | stimeout 太小 | 调到 10 秒以上,看日志里超时次数 |
黑屏时优先做一件事:ffplay -rtsp_transport tcp "rtsp://..."直接播源,源能出画面再回来查转换层。花屏基本是传输问题,不要先怀疑封装器;延迟变大则是缓冲和队列的问题,这两个方向差别很大,混在一起排查会走弯路。
5. 进阶技巧:多路流转发与 GOP Cache 秒开
5.1 多路流共享上游连接
监控集成一次接几十路摄像头是常态,如果每个 Web 端订阅都建一条 RTSP 连接,摄像头压力会成倍增长,很多设备端本身就限制单路并发。常用做法是按 URL 做引用计数:同一路流的地址作为 key,第一个订阅到来时创建上游拉流会话,后续订阅直接复用;最后一个订阅断开时才释放上游连接。
ConcurrentHashMap<String, StreamSession> sessions = new ConcurrentHashMap<>(); public void subscribe(String url, ClientConnection client) { StreamSession session = sessions.computeIfAbsent(url, k -> { StreamSession s = new StreamSession(k); s.start(); // 内部创建 FFmpegFrameGrabber 并循环抓帧 return s; }); session.addClient(client); } public void unsubscribe(String url, ClientConnection client) { StreamSession session = sessions.get(url); if (session != null && session.removeClient(client) == 0) { sessions.remove(url); session.stop(); } }computeIfAbsent保证并发下同一路流只创建一次拉流会话;addClient把新客户端的输出流注册进广播列表;最后一个客户端退出时removeClient返回 0,关闭上游。这里要特别注意 HTTP 连接断开检测,Netty 里通过 channelInactive 事件来驱动 unsubscribe,否则客户端刷新页面会残留僵尸连接。
5.2 GOP Cache 实现秒开
浏览器新接入时,如果拉流进度刚好落在两个关键帧之间,就必须要等下一个 I 帧才能显示画面。对 2 秒一个关键帧的流来说,用户会看到最长 2 秒黑屏。解决办法是缓存最近一个关键帧 tag 和 AVC sequence header,新客户端进来先发缓存再发实时数据:
private volatile byte[] lastKeyFrameTag; public void writeVideoTag(byte[] tag, boolean isKeyFrame) { if (isKeyFrame) { lastKeyFrameTag = tag; // 只缓存最新 I 帧,内存占用一个 tag 大小 } // 正常广播给所有客户端 }新订阅接入时先写 FLV header,再写缓存的 I 帧,然后回到正常转发流程。注意只发 I 帧还不够,要连同 H.264 的 SPS/PPS 配置帧一起缓存,否则播放器拿到 I 帧也解不出画面。这个技巧在分辨率越高、GOP 越大的流上收益越明显,4K 监控流的效果尤其明显。
5.3 用 ffprobe 验证整条链路的延迟
最后给一个可复现的验证方法。分别抓源端和输出端的第一帧 PTS,做差值:
# 抓输出流的第一帧时间戳 ffprobe -v error -show_entries packet=pts_time -select_streams v \ -of csv=p=0 http://127.0.0.1:8080/live/demo.flv | head -1 # 抓源流的第一帧时间戳 ffprobe -v error -show_entries packet=pts_time -select_streams v \ -of csv=p=0 rtmp://127.0.0.1:1935/live/demo | head -1两者相减得到引入的转换延迟,正常情况下应该在几百毫秒内。如果差值到秒级,优先检查 max_delay、输出队列是否堆积,以及多路流之间是否错误地共用了同一个时间轴。上线前把这个命令写进排查文档,遇到“网页比原始监控慢”的投诉时直接套用,比凭感觉调参数可靠得多。
本文还有配套的精品资源,点击获取