去年底我给自己的视频站做了一套播放系统。分片是 m3u8 + TS,PC 端 Chrome 打开就播,一切正常。

直到有人用 iPhone 打开,黑屏,转圈,一直转圈。

我拿自己的 iPhone 试了一遍,确实黑屏。同一套分片,Chrome 能播,Safari 不能。这就不是网络问题,是容器格式的问题。

那天晚上我把播放器逻辑翻了一遍,最后决定:不修播放器了,全部转成 MP4。115 集,一万多分钟的素材,从头转一遍。

这一步走了三个坑,每个都卡了大半天。

先搞清楚为什么 Safari 不播

这里得先说清背景。

视频播放有两个路子:

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"

几个参数说明:

批量跑,四路并发:

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 在文件头。

存储和带宽:转完才发现是另一笔账

视频这件事,转码只是第一步,真正烧钱的是带宽。

我的架构是页面和视频分开:

这么分的理由很实在:视频吃带宽,页面吃 CPU,混在一台机器上,一有人看视频,页面就卡。

算一笔账:

项目数值
单集平均时长2 分 10 秒
分辨率 / 码率1080p / 约 2.5 Mbps
单集体积约 40 MB
全集总量约 4.6 GB
同时 10 人看约 25 Mbps 上行

上海那台机器的上行是 30 Mbps,10 个人同时看基本就满了。再多就得考虑 CDN 或者限速。

这也是为什么我之前一直坚持"先转 MP4 再说"——MP4 支持 Range 请求,浏览器只下它需要的片段,不用整集加载,实际带宽比 HLS 分片省得多。

播放器这边怎么处理

转成 MP4 之后,播放逻辑反而简单了:

两套逻辑按扩展名分流,别混着来。

有个细节要注意:原生播放要用 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 聊聊。白天要上班,消息回得慢,看到都会回。