去年底我给自己的视频站做了一套播放系统。分片是 m3u8 + TS,PC 端 Chrome 打开就播,一切正常。
直到有人用 iPhone 打开,黑屏,转圈,一直转圈。
我拿自己的 iPhone 试了一遍,确实黑屏。同一套分片,Chrome 能播,Safari 不能。这就不是网络问题,是容器格式的问题。
那天晚上我把播放器逻辑翻了一遍,最后决定:不修播放器了,全部转成 MP4。115 集,一万多分钟的素材,从头转一遍。
这一步走了三个坑,每个都卡了大半天。
先搞清楚为什么 Safari 不播
这里得先说清背景。
视频播放有两个路子:
- 原生播放:浏览器自己解析,
<video src="xxx.mp4">直接放 - MSE 播放:JS 库(比如 hls.js)把分片喂给浏览器的 Media Source Extensions
iOS Safari 对 HLS 的原生支持很好,但它对 MSE 的支持有限——尤其当分片是 MPEG-TS 的时候,很多组合会直接失败。
我的站里,.m3u8 走 hls.js,.mp4 走原生。TS 分片走的就是 hls.js 这条路,在 Safari 上就黑了。
而 MP4 是原生支持的,Safari 和 Chrome 都认。所以理论上只要转成 MP4,问题就解决。
理论归理论,动手才知道坑在哪。
第一坑:-c copy 出来的文件,PC 能播,iPhone 不能
我的第一版命令很朴素,想着视频流不重编码,只换容器,应该秒转:
ffmpeg -i input.ts -c copy output.mp4
115 集跑完,只花了十几分钟。PC 上随便点开一集,能播。
iPhone 上,还是黑屏。
这就怪了——同样是 MP4,为什么 PC 认 iPhone 不认?
用 ffprobe 查了一下:
ffprobe -v error -show_entries stream=codec_name,codec_type,profile,pix_fmt -of default=noprint_wrappers=1 output.mp4
输出里视频是 h264,音频是 mp2。
问题在这儿:音频是 MP2。
MPEG-TS 里常见的音频编码是 MP2、AC3 或者 AAC。MP4 容器官方支持的音频编码只有 AAC、MP3 这些,MP2 不在列。
PC 上的 Chrome 解码器宽容,能硬放;iOS 的 AVFoundation 严格按标准,遇到 MP2 直接拒绝。
改成音频重编码:
ffmpeg -i input.ts -c:v copy -c:a aac -b:a 128k output.mp4
这一版,iPhone 能播了。
但有 7 集还是黑屏。
第二坑:时间戳不从零开始
那 7 集有个共同点:都是从中间切出来的分片,不是完整的一集。
用 ffprobe 看它们的起始时间戳:
ffprobe -v error -show_entries format=start_time -of default=noprint_wrappers=1 input.ts
输出 start_time=1023.400000 这种——时间戳从一千多秒开始。
MP4 容器的规范是时间轴从 0 开始。时间戳不从零,Safari 会算不出播放位置,表现就是"能加载、不播放"。
加一个参数:
ffmpeg -i input.ts -c:v copy -c:a aac -b:a 128k -avoid_negative_ts make_zero output.mp4
-avoid_negative_ts make_zero 会把负时间戳和偏移一并归零,重新算一遍时间轴。
这一版,剩下的 7 集有 5 集好了。
第三坑:SPS/PPS 缺失,只能重编码
最后 2 集,怎么调参数都不行。
深挖下去:H.264 码流里有两组关键参数,SPS(序列参数集)和 PPS(图像参数集),解码器必须拿到它们才能解画面。
在 TS 里,这两个参数会周期性重复插入(每个关键帧前面都带一份)。所以 TS 是"自描述"的,随便从哪里切都能解。
但 MP4 不一样——MP4 把这些参数抽出来,单独存在文件头的 avcC box 里,只存一份。
如果源 TS 里这两个参数不完整(比如切分时把开头那段丢了),转 MP4 的时候就没东西可存,avcC 是空的,解码器直接放弃。
这种没法靠参数救,只能重编码:
ffmpeg -i input.ts -c:v libx264 -preset medium -crf 20 -c:a aac -b:a 128k -movflags +faststart output.mp4
重编码慢得多,一集两分钟的视频要跑一分多钟。但只有 2 集,能接受。
最终的批量脚本
三坑踩完,最后一版命令:
ffmpeg -y -i "$in" \
-c:v copy \
-c:a aac -b:a 128k \
-avoid_negative_ts make_zero \
-movflags +faststart \
"$out"
几个参数说明:
-c:v copy:视频流不重编码,速度快,画质无损-c:a aac -b:a 128k:音频重编码成 AAC,兼容所有平台-avoid_negative_ts make_zero:时间轴归零-movflags +faststart:把 moov 元数据移到文件头,边下边播不用等整个文件
批量跑,四路并发:
find /data/ts -name "*.ts" -print0 | \
xargs -0 -P 4 -I{} sh -c '
in="$1"; out="${in%.ts}.mp4"
ffmpeg -y -i "$in" -c:v copy -c:a aac -b:a 128k \
-avoid_negative_ts make_zero -movflags +faststart "$out" \
&& echo "OK $out" || echo "FAIL $in"
' _ {}
-P 4 是并发数。我这台机器 4 核,开 4 个刚好跑满,再多就互相抢 IO 了。
115 集跑完,失败 2 个(就是那 2 集 SPS 缺失的),单独重编码补上。
验证:不能只看"能打开"
转完之后我没敢直接上线,做了三层验证:
第一层,文件层
for f in *.mp4; do
ffprobe -v error -select_streams v:0 \
-show_entries stream=codec_name,width,height \
-of csv=p=0 "$f"
done | sort | uniq -c
确认所有文件的编码、分辨率一致,没有混进来异常的。
第二层,播放器层
拿 iPhone 真机,逐集点开,看能不能播、能不能拖进度、快进之后画面正不正常。115 集我是抽了 20 集看的,重点挑之前黑屏的那几集。
第三层,浏览器层
PC Chrome 和 Safari 都过一遍,重点看 +faststart 有没有生效——拖进度条如果立刻响应,说明 moov 在文件头。
存储和带宽:转完才发现是另一笔账
视频这件事,转码只是第一步,真正烧钱的是带宽。
我的架构是页面和视频分开:
- 页面、接口、数据库在一台服务器(香港)
- 视频文件放在另一台(上海),走 HTTP 直连
这么分的理由很实在:视频吃带宽,页面吃 CPU,混在一台机器上,一有人看视频,页面就卡。
算一笔账:
| 项目 | 数值 |
|---|---|
| 单集平均时长 | 2 分 10 秒 |
| 分辨率 / 码率 | 1080p / 约 2.5 Mbps |
| 单集体积 | 约 40 MB |
| 全集总量 | 约 4.6 GB |
| 同时 10 人看 | 约 25 Mbps 上行 |
上海那台机器的上行是 30 Mbps,10 个人同时看基本就满了。再多就得考虑 CDN 或者限速。
这也是为什么我之前一直坚持"先转 MP4 再说"——MP4 支持 Range 请求,浏览器只下它需要的片段,不用整集加载,实际带宽比 HLS 分片省得多。
播放器这边怎么处理
转成 MP4 之后,播放逻辑反而简单了:
.mp4→ 原生<video>,src直接给文件地址.m3u8→ 还是 hls.js
两套逻辑按扩展名分流,别混着来。
有个细节要注意:原生播放要用 load() + oncanplay 判断就绪,不能一赋值就调 play()。iOS 上如果没等 canplay 就播,会抛 NotAllowedError 或者静默失败。
video.src = url;
video.load();
video.addEventListener('canplay', () => {
video.play().catch(() => {});
}, { once: true });
最后
整件事做完,我最大的体会是:"能播"和"能正确播"是两回事。
-c copy 出来的文件,PC 上看着一切正常,其实是靠 Chrome 宽容的解码器兜着。换到 iOS 严格的实现上,问题全露出来——音频容器不支持、时间戳不从零、参数集缺失。
这三个坑,任何一个都不会报错,只会让你看到黑屏。
如果你也在做视频站的转码,记住三件事:音频必须重编码成 AAC、时间戳必须归零、+faststart 别忘了。剩下的,就是拿真机一台台试。
如果你也在折腾这套东西,碰到卡住的地方,可以加我微信 lige0552 聊聊。白天要上班,消息回得慢,看到都会回。