视频转码踩坑记:从m3u8到TS片段的时间戳连续性问题
视频转码踩坑记:从m3u8到TS片段的时间戳连续性问题
最近在做一个视频投屏的项目,遇到了一个挺有意思的问题。简单来说,就是要把视频转成HLS格式(m3u8 + TS片段),让各种设备都能正常播放。听起来挺简单的,结果踩了一堆坑。
背景:为什么需要改转码方案
最开始我们的方案是统一将视频文件转成m3u8+ts格式,边播边转。这种方式的好处是节省存储空间,但有个致命问题:生成的m3u8文件没有 #EXT-X-ENDLIST 标签。
这个标签是干嘛的呢?它告诉播放器”这个视频已经完整了,不是直播流”。没有这个标签,像FireTV、DLNA这些设备就不知道视频有多长,也就没法做seek操作。用户想跳转到某个时间点?不好意思,不支持。
所以我们就想,能不能先一次性生成完整的m3u8文件(包含所有片段列表和 #EXT-X-ENDLIST),然后TS片段按需转码?这样既能解决seek问题,又不会一次性转码所有片段浪费资源。
第一版方案:预生成m3u8 + 按需转码TS
思路很简单:
- 播放开始时,先用Java手动生成一个完整的m3u8文件(用FFmpeg获取视频时长),里面列出所有TS片段的信息
- 播放器请求某个TS片段时,如果文件不存在,就实时转码生成
- 用内存缓存管理m3u8和片段信息,避免重复解析文件
实现起来也不复杂,主要就是:
generateFullM3U8(): 手动生成完整m3u8并解析到内存(用FFmpeg获取时长)convertSegment(index): 按需转码单个TS片段
代码写完了,测试一下,m3u8格式正确,也能手动seek,看起来没问题。结果一放到FireTV和DLNA上,又出问题了。
第一个坑:TS片段首帧不是IDR帧
问题表现:FireTV和DLNA只能播放第一个TS片段,播放完就卡住了,不会自动播放下一个。但是浏览器播放器就能正常播放。
一开始我还以为是m3u8格式的问题,检查了半天发现格式完全正确。然后怀疑是网络请求的问题,抓包看也没问题。最后用ffprobe检查TS文件,才发现问题所在:
每个TS片段的第一帧虽然类型是I帧(TYPE=I),但是 KEY= 是空的,也就是说它不是IDR帧。
什么是IDR帧?简单说就是可以独立解码的帧,不需要依赖前面的帧。HLS规范要求每个TS片段的第一帧必须是IDR帧,这样播放器才能从任意片段开始播放。浏览器比较宽容,但TV设备就比较严格了。
解决方案:调整FFmpeg参数
要让每个TS片段的第一帧是IDR帧,需要在FFmpeg转码时加一些参数:
1 | |
这里我用了最激进的方式:强制每个帧都是关键帧。虽然文件会变大,但至少能先验证问题是不是出在IDR帧上。如果测试通过,再优化成合理的GOP大小。
第二个坑:时间戳不连续导致无法连续播放
IDR帧的问题解决了,以为就完事了。结果测试发现,虽然每个片段都能播放,但是播放完第一个片段后,还是不能自动播放下一个。
用ffprobe检查了一下TS文件的时间戳:
1 | |
发现问题了:每个TS片段的DTS都从接近0开始(1.4秒左右),而不是连续的时间戳。这样播放器就不知道这些片段之间的时间关系,自然无法连续播放。
为什么会这样?
因为我们用了 -ss startTime 来seek到指定位置,然后转码。FFmpeg默认会把输出流的时间戳重置,导致每个片段的时间戳都从0开始。
解决方案:使用-output_ts_offset
要让每个TS片段的时间戳连续,需要用 -output_ts_offset 参数设置输出时间戳的偏移量:
1 | |
这样每个片段的时间戳就会从对应的startTime开始:
- segment_000.ts (startTime=0): DTS从0开始
- segment_001.ts (startTime=4): DTS从4开始
- segment_002.ts (startTime=8): DTS从8开始
播放器看到连续的时间戳,就能正确衔接各个片段了。
完整的转码参数
最后,完整的TS片段转码命令大概是这样的:
1 | |
考虑到ss和-i的顺序对性能的影响,实际代码中我把 -ss 放在了 -i 之前,以提高seek效率。
总结
这次踩坑让我学到了几个点:
- HLS规范很重要:IDR帧、时间戳连续性这些细节,浏览器可能不care,但TV设备很严格
- 测试要全面:不能只在浏览器上测试,各种设备都要测
- FFmpeg参数很关键:
-output_ts_offset这种参数平时用不到,但关键时刻能救命 - 按需转码是个好思路:既解决了seek问题,又不会浪费资源
现在方案已经稳定运行了,FireTV和DLNA都能正常播放和seek。虽然中间踩了不少坑,但最终效果还是不错的。