视频转码踩坑记:从m3u8到TS片段的时间戳连续性问题

视频转码踩坑记:从m3u8到TS片段的时间戳连续性问题

最近在做一个视频投屏的项目,遇到了一个挺有意思的问题。简单来说,就是要把视频转成HLS格式(m3u8 + TS片段),让各种设备都能正常播放。听起来挺简单的,结果踩了一堆坑。

背景:为什么需要改转码方案

最开始我们的方案是统一将视频文件转成m3u8+ts格式,边播边转。这种方式的好处是节省存储空间,但有个致命问题:生成的m3u8文件没有 #EXT-X-ENDLIST 标签。

这个标签是干嘛的呢?它告诉播放器”这个视频已经完整了,不是直播流”。没有这个标签,像FireTV、DLNA这些设备就不知道视频有多长,也就没法做seek操作。用户想跳转到某个时间点?不好意思,不支持。

所以我们就想,能不能先一次性生成完整的m3u8文件(包含所有片段列表和 #EXT-X-ENDLIST),然后TS片段按需转码?这样既能解决seek问题,又不会一次性转码所有片段浪费资源。

第一版方案:预生成m3u8 + 按需转码TS

思路很简单:

  1. 播放开始时,先用Java手动生成一个完整的m3u8文件(用FFmpeg获取视频时长),里面列出所有TS片段的信息
  2. 播放器请求某个TS片段时,如果文件不存在,就实时转码生成
  3. 用内存缓存管理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
2
3
4
5
6
7
8
9
10
cmdList.add("-vcodec");
cmdList.add("libx264");
cmdList.add("-g");
cmdList.add("1"); // GOP大小为1,强制每帧都是关键帧
cmdList.add("-keyint_min");
cmdList.add("1");
cmdList.add("-sc_threshold");
cmdList.add("0"); // 禁用场景切换检测
cmdList.add("-x264opts");
cmdList.add("keyint=1:min-keyint=1:scenecut=0");

这里我用了最激进的方式:强制每个帧都是关键帧。虽然文件会变大,但至少能先验证问题是不是出在IDR帧上。如果测试通过,再优化成合理的GOP大小。

第二个坑:时间戳不连续导致无法连续播放

IDR帧的问题解决了,以为就完事了。结果测试发现,虽然每个片段都能播放,但是播放完第一个片段后,还是不能自动播放下一个。

ffprobe检查了一下TS文件的时间戳:

1
2
3
4
5
6
7
# segment_000.ts
PTS=1 DTS=1.412622 TYPE=I
PTS=0 DTS=1.445956 TYPE=P

# segment_001.ts
PTS=1 DTS=1.454667 TYPE=I
PTS=0 DTS=1.488000 TYPE=P

发现问题了:每个TS片段的DTS都从接近0开始(1.4秒左右),而不是连续的时间戳。这样播放器就不知道这些片段之间的时间关系,自然无法连续播放。

为什么会这样?

因为我们用了 -ss startTime 来seek到指定位置,然后转码。FFmpeg默认会把输出流的时间戳重置,导致每个片段的时间戳都从0开始。

解决方案:使用-output_ts_offset

要让每个TS片段的时间戳连续,需要用 -output_ts_offset 参数设置输出时间戳的偏移量:

1
2
3
4
5
6
7
// 设置输出时间戳偏移,确保每个 segment 的 PTS/DTS 连续
cmdList.add("-output_ts_offset");
cmdList.add(String.valueOf(startTime)); // startTime是片段的开始时间(秒)

// 生成PTS,确保时间戳连续
cmdList.add("-fflags");
cmdList.add("+genpts");

这样每个片段的时间戳就会从对应的startTime开始:

  • segment_000.ts (startTime=0): DTS从0开始
  • segment_001.ts (startTime=4): DTS从4开始
  • segment_002.ts (startTime=8): DTS从8开始

播放器看到连续的时间戳,就能正确衔接各个片段了。

完整的转码参数

最后,完整的TS片段转码命令大概是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
ffmpeg -i input.mp4 \
-ss 4.0 \ # seek到片段开始位置
-t 4.0 \ # 片段时长
-acodec aac -ac 2 \ # 音频编码
-vcodec libx264 \ # 视频编码
-f mpegts \ # 输出TS格式
-output_ts_offset 4.0 \ # 时间戳偏移,确保连续
-fflags +genpts \ # 生成PTS
-threads 4 \
-preset ultrafast \
-y \
output.ts

考虑到ss和-i的顺序对性能的影响,实际代码中我把 -ss 放在了 -i 之前,以提高seek效率。

总结

这次踩坑让我学到了几个点:

  1. HLS规范很重要:IDR帧、时间戳连续性这些细节,浏览器可能不care,但TV设备很严格
  2. 测试要全面:不能只在浏览器上测试,各种设备都要测
  3. FFmpeg参数很关键-output_ts_offset 这种参数平时用不到,但关键时刻能救命
  4. 按需转码是个好思路:既解决了seek问题,又不会浪费资源

现在方案已经稳定运行了,FireTV和DLNA都能正常播放和seek。虽然中间踩了不少坑,但最终效果还是不错的。


视频转码踩坑记:从m3u8到TS片段的时间戳连续性问题
https://blog.201912.xyz/2025/12/08/m3u8-convert/
作者
jin123d
发布于
2025年12月8日
许可协议