上周帮朋友处理一批录播文件,目录里躺着两百多个.ts分片和一个index.m3u8,他用普通播放器一个个点着看还行,想剪一段做课程回放就彻底没辙了。这种场景我遇到过太多次——凡是走流媒体协议播出来的内容,落到本地往往就是一串碎片加一个索引文件,而真正好用的仍然是一个规规矩矩的 mp4。FFmpeg 就是干这个的:它能把散装分片按索引拼回一个完整的 mp4,也能反过来把一个规整的 mp4 切成 m3u8 索引加一堆 ts 分片,交给播放器或网页去点播。
这篇东西我不打算写成命令手册,那种东西查文档就有。我想聊的是我自己在 mp4 与 m3u8 双向转换上踩过的坑:为什么有的命令跑完只有声音没有画面,为什么切片之后网页播放器一直转圈,为什么明明-c copy几秒就完成的活儿,非有人要重编码跑二十分钟。内容会覆盖 FFmpeg 的安装与自检、m3u8 转 mp4 的几种典型路径、mp4 切片成 HLS 的参数计算、加密流的处理、以及失败之后怎么定位。刚接触 FFmpeg 的人可以照着抄命令,已经用了一段时间的人可以重点看参数取舍和排查思路那几节。前提说清楚:所有操作只针对你自己录制的、或者你已经获得授权的素材,别拿去做不该做的事。
1. 先搞清楚 m3u8 和 mp4 到底差在哪
1.1 一个是播放清单,一个是完整容器
很多人第一次接触 m3u8 会以为它是某种视频格式,其实它就是个纯文本清单。你用记事本打开一个 m3u8,看到的通常是这样的内容:
#EXTM3U #EXT-X-VERSION:3 #EXT-X-TARGETDURATION:10 #EXT-X-MEDIA-SEQUENCE:0 #EXTINF:10.000, seg_000.ts #EXTINF:10.000, seg_001.ts #EXT-X-ENDLIST#EXTINF后面跟的是这一段的时长,下一行是分片文件名。播放器读这个清单,按顺序把每个 ts 拉下来、解码、拼起来播放。所以 m3u8 本身不包含任何画面数据,它只是一张目录表。mp4 则完全不同,它是把视频轨、音频轨、字幕轨、时间戳索引全部封装在一个文件里的容器,moov盒子里记着每一帧的位置和时长,播放器打开它就知道整个影片的结构。
这个根本差别决定了两者的使用位置:mp4 适合存放和搬运,一个文件走天下;m3u8 适合分发,可以按需加载、可以切码率、可以做直播。理解了这一点,你就明白为什么转换本质上是"拆表"和"装订"两件事,而不是像转码那样要重新计算画面。
1.2 为什么流媒体非要拆成碎片
有人会问,既然 mp4 这么好用,为什么还要费劲拆开?核心原因有三个。第一是起播速度,一个 2GB 的 mp4 如果moov盒子在文件末尾,播放器必须把整个文件拉完才能开始播;而 m3u8 只需要先拿到几百字节的清单和第一个分片,一两秒就能出画面。第二是码率自适应,同一个视频准备 1080p、720p、480p 三个版本的清单,播放器根据当前网速自己切换,网络抖动时降码率而不是卡住。第三是直播场景,内容是一段段持续产生的,根本不存在一个"完整的文件"可以封装。
反过来说,当内容已经躺在你硬盘上、不需要自适应、也不需要考虑起播延迟的时候,碎片化就是纯粹的麻烦。你会遇到文件名带序号、目录散乱、播放器不支持直接打开清单、想做剪辑软件却识别不了这一堆东西。这就是 m3u8 转 mp4 这个方向的需求来源。
1.3 两个方向的转换各自解决什么问题
m3u8 → mp4是我用得最多的方向。典型场景是:录播平台导出的一堆分片要归档,需要合并成一个文件方便长期保存;或者你手里的课程素材要丢进剪辑软件,而剪辑软件只认标准容器。这个方向的核心诉求是"无损、快速、音画对齐",所以绝大多数情况应该走-c copy直接复制流,不重编码。
mp4 → m3u8则是分发方向。你自己拍的视频要放到网页上做点播,希望用户点开就能播、拖动进度条秒响应、手机上不卡顿,这时候切片成 HLS 是最省事的方案。这个方向的核心诉求是"分片均匀、关键帧对齐、兼容性好",参数选择比第一个方向讲究得多。
两个方向我都建议先跑一遍最小命令看结果,再决定要不要调参。很多人一上来就堆一大堆参数,出了问题反而不知道是哪一个导致的。
2. 把 FFmpeg 装到能跑起来
2.1 Windows:解压版、PATH 与版本区别
Windows 上最省事的办法是用官方提供的编译版压缩包,下载下来是个.7z,比如名字里带essentials_build的那种。解压之后你会看到bin目录里有三个关键可执行文件:ffmpeg.exe负责转换,ffprobe.exe负责探测分析,ffplay.exe是个轻量播放器用来快速验证。很多人只把ffmpeg.exe拖出来用,后面想用ffprobe看流信息又得重新找,建议整个目录一起放到一个固定路径,比如D:\tools\ffmpeg。
essentials_build和full_build的区别在于内置编码器的数量。前者只打包了最常见的那些(H.264、H.265、AAC、MP3、VP9 等),体积小;后者把能塞的都塞进去了,包括一些冷门格式和实验性编码器。做 mp4 与 m3u8 互转这件事,essentials 完全够用,我真没遇到过因为编码器不全而失败的案例。
接下来是配置环境变量,这一步是新手最容易卡住的地方。把D:\tools\ffmpeg\bin加到系统 PATH 里,然后必须重新开一个命令行窗口,老窗口读的还是旧的环境变量。我见过有人折腾半小时以为装失败了,其实只是没重开 cmd。
顺便提一句 32 位和 64 位的问题。如果你还在用 32 位系统,得单独找 32 位的编译版,64 位版本在上面跑不起来,会直接报不是有效应用程序。现在还在用老系统的机器不少,这个坑挺常见。
2.2 Linux:包管理器安装与带硬件加速的编译
如果只是要跑转换命令,sudo apt install ffmpeg就够了,装完立刻能用。但如果你要处理大量视频、希望用显卡加速,发行版仓库里的版本往往没开 NVENC,这时候就得自己编译或者找第三方编译版。
编译的核心是让 configure 阶段能找到 CUDA 相关头文件和库,典型的配置长这样:
./configure \ --enable-nonfree \ --enable-cuda-nvcc \ --enable-nvdec --enable-nvenc --enable-cuvid \ --enable-ffnvcodec \ --enable-libnpp \ --extra-cflags=-I/usr/local/cuda/include \ --extra-ldflags=-L/usr/local/cuda/lib64 \ --enable-gpl --enable-libx264 --enable-libx265编译完之后,用ffmpeg -hide_banner -encoders | grep nvenc检查有没有h264_nvenc和hevc_nvenc,有就说明编译成功了。这里有个实际经验:驱动版本和 CUDA 版本不匹配是编译失败的头号原因,报错信息通常出现在checking for nvcc那一步,遇到就先确认nvcc --version能不能正常输出。
2.3 装完必做的三条自检命令
装好之后别急着跑正式任务,先做三个检查,能把后面 80% 的"命令跑不通"问题提前排除。
第一条,看版本和构建配置:
ffmpeg -version输出第一行是版本号,下面的configuration:里能看到是否启用了--enable-libx264之类的选项。这个信息很重要,比如你写的命令里有-c:v libx264,但你的构建里没有这个编码器,就会报Unknown encoder 'libx264'。
第二条,看某个编码器是否可用:
ffmpeg -hide_banner -encoders | grep -Ei "264|265|aac|nvenc"第三条,用 ffprobe 确认能读懂文件:
ffprobe -v error -show_format -show_streams -of json input.mp4这三条能跑通,说明环境没问题,后面出问题就是命令或素材本身的事了,排查范围一下缩小一半。
2.4 关于版本选择的一点个人看法
FFmpeg 的版本迭代挺快,新版对一些历史遗留问题做了自动处理。比如从 ts 里复制 AAC 音频到 mp4 时,老版本必须手动加-bsf:a aac_adtstoasc,新版本会自动帮你加。又比如 HLS 切片时 H.264 的 Annex-B 转换,新版 hls 复用器也会自动处理。
我的建议是:生产环境用稳定的大版本,别追最新。如果你在网上抄到的命令里有一堆手动加 bitstream filter 的写法,用新版本跑也不会有问题,那些 filter 通常会被忽略或者幂等执行。反过来,用老版本去跑只写了两三个参数的新教程命令,就可能失败。
3. m3u8 转 mp4:把碎片拼回完整文件
3.1 本地分片目录的标准操作
假设你有一个目录,里面有index.m3u8和一堆seg_000.ts、seg_001.ts……最直接的命令是:
ffmpeg -allowed_extensions ALL -i index.m3u8 -c copy -movflags +faststart output.mp4逐个拆解一下。-allowed_extensions ALL是因为 m3u8 里写的分片扩展名有时候不是.ts,可能被改成.jpg、.png之类的伪装后缀,默认情况下 ffmpeg 出于安全考虑会拒绝加载非标准扩展名。-c copy表示音视频轨直接复制,不重新编码,这是速度的关键——一小时视频几秒钟就能合并完。-movflags +faststart会把moov盒子搬到文件头部,这样输出的 mp4 起播很快,也方便网页播放。
注意:如果你的 m3u8 里写的是绝对路径或者带域名的地址,即使文件在本地,ffmpeg 也会尝试走网络协议。这时候需要加上
-protocol_whitelist file,http,https,tcp,tls,crypto,否则会报协议不在白名单里。
如果 m3u8 文件丢了,只剩下一堆 ts 分片,也能救。用一个文本文件列出所有分片就行:
file 'seg_000.ts' file 'seg_001.ts' file 'seg_002.ts'然后:
ffmpeg -f concat -safe 0 -i filelist.txt -c copy -movflags +faststart output.mp4-safe 0是为了允许文件路径中出现特殊字符,缺了它会报不安全路径。生成这个列表不用手敲,Linux 下ls seg_*.ts | sort | sed "s/^/file '/;s/$/'/" > filelist.txt一条命令搞定。注意一定要排序,ls默认的字典序对seg_1.ts、seg_10.ts、seg_2.ts这种不带补零的命名会排错,合并出来顺序全乱。
3.2 直接喂网络地址
如果分片还在服务器上,直接给 ffmpeg 一个完整 URL 就行:
ffmpeg -headers "User-Agent: Mozilla/5.0" -i "https://example.com/hls/index.m3u8" \ -c copy -movflags +faststart output.mp4几个实用参数值得记住。-headers用来附加 HTTP 请求头,有些源会校验 Referer 或 UA,不加就返回 403。-user_agent是-headers的简化写法,只改 UA 的时候用它更短。-reconnect 1 -reconnect_streamed 1 -reconnect_delay_max 5在网络不稳定时很有用,断了会自动重连,不至于跑了一半任务失败。
-rw_timeout控制单次读超时,单位是微秒,设成-rw_timeout 15000000就是 15 秒。这个参数在处理响应慢的源时能避免无限等待。我一般会把它和-timeout搭配着设,具体值根据源站响应速度调。
还有一个经验:网络源转本地文件时,输出文件名别和输入同名同目录,否则某些情况下可能出现读写冲突,输出直接变成 0 字节。
3.3-c copy还是重编码,怎么判断
这是新手最容易选错的地方。我的判断标准很简单,按优先级来:
| 情况 | 选择 | 理由 |
|---|---|---|
| 分片是 H.264 + AAC,目标就是普通 mp4 | -c copy | 无损、极快、CPU 几乎不动 |
| 分片是 H.265,播放设备不支持 | 重编码为 H.264 | 兼容性优先 |
| 音画不同步、时间戳混乱 | 先试-c copy加时间戳参数 | 大部分情况不用重编码 |
| 需要裁剪、缩放、加水印 | 必须重编码 | 滤镜链无法在 copy 模式下工作 |
重编码的典型命令是把-c copy换成:
-c:v libx264 -preset medium -crf 20 -c:a aac -b:a 128k-crf是画质控制,数值越小越清晰、文件越大,18 到 23 是常用区间,20 基本是肉眼无损。-preset控制编码速度,从ultrafast到veryslow,越慢压缩率越高。我实测一小时 1080p 素材,medium大概需要 15 到 30 分钟,取决于 CPU。
有显卡的话,把-c:v libx264换成-c:v h264_nvenc,速度能快 5 到 10 倍,但同码率下画质略差一点。我的用法是:中间产物用 NVENC 图快,最终交付用 libx264 图质量。
3.4 遇到加密流怎么处理
有些 m3u8 里会有这么一行:
#EXT-X-KEY:METHOD=AES-128,URI="key.key",IV=0x...这说明分片被 AES-128 加密了,需要密钥才能解。如果你手上有密钥文件,在本地目录里放好,直接跑-allowed_extensions ALL那条命令,ffmpeg 会自动读取并解密。如果密钥是通过 HTTP 提供的,需要在白名单里加上crypto和https:
ffmpeg -protocol_whitelist file,http,https,tcp,tls,crypto -i index.m3u8 -c copy output.mp4常见的失败现象是Invalid data found when processing input或者解出来的文件全是噪音。第一个原因通常是密钥长度不对——AES-128 的密钥文件必须是正好 16 字节,多一个换行符都会失败,用xxd key.key看一下就很清楚。第二个原因可能是 IV 不匹配,如果 m3u8 里指定了 IV,就要用指定的那个,不能自己随便生成。
提示:如果你不确定素材是否加密,先用 ffprobe 跑一遍源地址,能读出流信息说明没加密或者密钥可公开获取;直接报错就要回头检查清单文件。
3.5 一批目录的批处理写法
真实工作里很少只转一个文件。假设你有case01到case20二十个目录,每个里面都有一个 m3u8,Linux 下一条循环就完事:
for d in case*/; do name=$(basename "$d") ffmpeg -hide_banner -loglevel error -allowed_extensions ALL \ -i "${d}index.m3u8" -c copy -movflags +faststart "out/${name}.mp4" done-loglevel error让输出只保留错误信息,批量跑的时候屏幕不会被刷爆。-hide_banner去掉版本横幅。这两个参数在脚本里几乎是标配。
Windows 下用 PowerShell 写循环也可以:
Get-ChildItem -Directory | ForEach-Object { $n = $_.Name ffmpeg -allowed_extensions ALL -i "$($_.FullName)\index.m3u8" ` -c copy -movflags +faststart "out\$n.mp4" }批量任务最怕的是跑到一半某个文件失败,后面全乱。我的做法是每个文件单独输出一行状态,失败就记下名字跳过,跑完统一处理失败的几个,比一路中断强得多。
4. mp4 转 m3u8:切片、加密与播放列表
4.1 最小可用的切片命令
从零开始做一个能播的 HLS 目录,基础命令只有一行:
ffmpeg -i input.mp4 -c copy -bsf:v h264_mp4toannexb \ -f hls -hls_time 10 -hls_list_size 0 \ -hls_segment_filename "out_%03d.ts" \ out.m3u8跑完你会得到out.m3u8和out_000.ts、out_001.ts……每个分片大约 10 秒。%03d是序号格式,会生成000、001这样的三位数字,排序稳定。切片速度极快,因为-c copy同样不重编码。
-bsf:v h264_mp4toannexb这个 bitstream filter 要专门说一下。mp4 里存 H.264 用的是一种长度前缀的格式,而 ts 里用的是另一种起始码的格式,两者不通用。这个 filter 就是做转换的。新版 ffmpeg 的 hls 复用器会自动插入它,但显式写上没坏处,尤其是你在用一些较老的构建版本时。
4.2 四个关键参数逐个拆
参数选错是切片效果差的根源,这四个必须搞清楚。
-hls_time是目标分片时长,单位秒。它只是"目标",实际分片长度取决于关键帧位置。因为 ts 只能在关键帧处切开,如果原视频 GOP 是 4 秒,你要 6 秒的分片,实际会切成 4 秒或 8 秒。想要精确控制,原视频必须用-g参数强制关键帧间隔。计算方法很简单:关键帧间隔 = 帧率 × 目标分片时长。25fps 的视频要 6 秒分片,-g 150;30fps 要 4 秒分片,-g 120。
-hls_list_size是清单里保留多少个分片。默认值是 5,也就是滚动窗口模式,只保留最近 5 个分片在清单里——这是给直播用的。做点播必须设成0,表示保留全部分片。我见过有人切完发现只有前几分钟能播,后面全黑,就是这个参数没改。
-hls_segment_filename指定分片命名模板。用%d系列占位符,也可以用%v表示码率变体编号,做多码率时会用到。命名里尽量别用中文和空格,某些服务器返回时会有编码问题。
-hls_playlist_type vod明确标记清单是点播类型,会在清单末尾加#EXT-X-ENDLIST。播放器看到这个标记就知道内容已经完整,可以显示总时长。不加的话有些播放器会以为是直播流,进度条不显示总长度。
完整的点播切片命令:
ffmpeg -i input.mp4 -c copy -bsf:v h264_mp4toannexb \ -f hls -hls_time 6 -hls_list_size 0 \ -hls_playlist_type vod \ -hls_segment_filename "seg_%04d.ts" \ index.m3u84.3 ts 分片还是 fmp4 分片
HLS 支持两种分片格式。传统的.ts是 MPEG-TS 流,兼容性最好,从老播放器到各种移动端都能播,这也是目前用得最多的形式。另一种是-hls_segment_type fmp4生成的.m4s分片,配合一个init.mp4初始化文件,体积略小、切换码率时更平滑,新设备支持良好,但老设备可能识别不了。
切换方式:
ffmpeg -i input.mp4 -c copy \ -f hls -hls_time 6 -hls_list_size 0 \ -hls_segment_type fmp4 \ -hls_fmp4_init_filename "init.mp4" \ -hls_segment_filename "seg_%04d.m4s" \ index.m3u8我的选择原则:面向大众用户的公开点播用 ts,因为不知道对面是什么设备;内部使用、目标设备明确的新项目用 fmp4。还有一种-hls_flags single_file模式,把全部分片塞进一个大文件,清单里用字节范围索引,适合分片太多导致文件数爆炸的场景,但服务器必须支持 Range 请求。
4.4 切片加密
如果内容需要一定的访问控制,HLS 支持 AES-128 加密。第一步生成密钥和 IV:
openssl rand 16 > enc.key openssl rand -hex 16第二条命令输出的是 32 位十六进制字符串,作为 IV 记下来。然后创建密钥信息文件enc.keyinfo,格式是三行:
https://your-domain.com/key/enc.key /path/to/enc.key 0123456789abcdef0123456789abcdef第一行是播放器获取密钥的 URL,第二行是服务端本地密钥文件路径,第三行是 IV。切片时加上:
ffmpeg -i input.mp4 -c copy \ -f hls -hls_time 6 -hls_list_size 0 \ -hls_key_info_file enc.keyinfo \ -hls_segment_filename "enc_%04d.ts" \ index.m3u8生成的分片就是加密的了,播放器必须先请求 key URL 拿到密钥才能解。实际部署时,那个 key URL 通常挂在一个需要鉴权的接口上,用临时令牌控制访问,这才是加密真正起作用的部分——分片本身加密只防君子,访问控制才是关键。另外要注意,enc.key文件千万不要和分片放在同一个公开目录里,等于把钥匙插在门上。
4.5 多码率 master playlist
单码率切片只能叫"HLS 化",真正发挥 HLS 优势的是多码率自适应。做法是在一次 ffmpeg 调用里生成多个分辨率的变体,再输出一个 master 清单。
ffmpeg -i input.mp4 \ -filter_complex "[0:v]split=2[v1][v2]; \ [v1]scale=w=1280:h=720[v1out]; \ [v2]scale=w=854:h=480[v2out]" \ -map "[v1out]" -c:v:0 libx264 -b:v:0 2600k -maxrate:v:0 2800k -bufsize:v:0 4000k \ -map "[v2out]" -c:v:1 libx264 -b:v:1 1200k -maxrate:v:1 1300k -bufsize:v:1 2000k \ -map a:0 -map a:0 -c:a aac -b:a 128k -ac 2 \ -g 150 -keyint_min 150 -sc_threshold 0 \ -f hls -hls_time 6 -hls_playlist_type vod \ -hls_segment_filename "v%v/seg_%04d.ts" \ -master_pl_name master.m3u8 \ -var_stream_map "v:0,a:0 v:1,a:1" \ "v%v/index.m3u8"这段命令里有几个点值得单独解释。split=2把视频流复制成两路,分别缩放到 720p 和 480p。-b:v是目标码率,-maxrate是峰值上限,-bufsize是缓冲区大小,一般取maxrate的 1.5 倍左右,三者配合可以让码率曲线更平滑。-g 150 -keyint_min 150 -sc_threshold 0是必须的,它强制所有变体在相同时间点产生关键帧,否则播放器切换码率时会出现花屏或者卡顿,这是多码率切片最容易忽略的一点。
-var_stream_map "v:0,a:0 v:1,a:1"把视频轨和音频轨配对成两个变体,%v在文件名和目录里都会被替换成变体编号。跑完之后你会得到v0/、v1/两个目录加一个master.m3u8,播放器读 master 自动选择合适码率。码率档位的搭配经验是:相邻档位之间码率差 1.5 到 2 倍,差太少切换没意义,差太多视觉跳变明显。
5. 三个真实场景的完整实操
5.1 场景一:录播分片合并加完整性校验
这是我做得最多的一类活儿。手上一个目录,index.m3u8加 200 多个 ts,需求是合并成 mp4 并确认没问题。
先看一眼清单确认分片数量:
grep -c "\.ts" index.m3u8 ls *.ts | wc -l两个数字对不上就说明有分片缺失,合并出来的视频会有跳帧。这种情况先把缺的补上,别硬合。
然后合并:
ffmpeg -hide_banner -allowed_extensions ALL -i index.m3u8 \ -c copy -movflags +faststart \ -loglevel warning \ output.mp4跑完之后一定要校验,这一步不能省:
ffprobe -v error -show_entries format=duration,size -of default=nw=1 output.mp4对比一下清单里#EXTINF的总和,差距在 1 秒以内算正常,差得多就说明有分片没合进去。再快速播一遍开头、中间、结尾各 10 秒,确认音画同步。我用的是ffplay -ss 00:10:00 -t 10 output.mp4,直接跳到中间看一段。
注意:合并出来的文件容器时长和实际可播时长可能不一致,尤其是最后一个分片不完整的时候。校验时以实际能播到最后为准,不要只看 ffprobe 的数字。
5.2 场景二:在线地址一次成型
有些内容只提供在线清单地址,需要一次性拉下来存成 mp4。这时候关键在网络稳定性:
ffmpeg -hide_banner \ -user_agent "Mozilla/5.0 (Windows NT 10.0; Win64; x64)" \ -headers "Referer: https://example.com/player\r\n" \ -reconnect 1 -reconnect_streamed 1 -reconnect_delay_max 5 \ -rw_timeout 20000000 \ -protocol_whitelist file,http,https,tcp,tls,crypto \ -i "https://example.com/hls/index.m3u8" \ -c copy -movflags +faststart output.mp4-headers里多个头用\r\n分隔,末尾也要有。Referer 这个头在不少源上是必须的,缺了直接 403。如果跑到一半断了,ffmpeg 会按-reconnect_delay_max指定的最大间隔重试,网络恢复后能接着拉。
另一种做法是先分片下载再本地合并。aria2c这类工具支持多线程分段下载,速度通常比 ffmpeg 单线程快,适合源站带宽充足的情况。下载完之后按 3.1 节的办法用 concat 合并。缺点是分片文件名可能重复或者没有序号规律,需要人工整理一下列表顺序。
5.3 场景三:自有视频做网页点播加服务器配置
假设你拍了一段视频要放在自己的网页上点播。流程是:先预处理成标准格式,再切片,最后配置服务器。
预处理这一步很多人忽略,但它决定后面顺不顺。原视频的编码格式、GOP 结构、时间戳都可能是乱的,直接切片会得到长短不一的分片:
ffmpeg -i raw.mp4 \ -c:v libx264 -preset medium -crf 20 \ -profile:v high -level 4.1 -pix_fmt yuv420p \ -g 150 -keyint_min 150 -sc_threshold 0 \ -c:a aac -b:a 128k -ac 2 -ar 44100 \ -movflags +faststart \ input.mp4-pix_fmt yuv420p是兼容性关键,很多手机的相机输出 yuvj420p 或者 10bit 格式,网页播放器解不了,转成 yuv420p 就通吃了。-profile:v high -level 4.1是目前兼容性最好的组合,从老手机到新浏览器都支持。
然后切片:
ffmpeg -i input.mp4 -c copy -bsf:v h264_mp4toannexb \ -f hls -hls_time 6 -hls_list_size 0 \ -hls_playlist_type vod \ -hls_segment_filename "hls/seg_%04d.ts" \ hls/index.m3u8服务器这边有两个坑。第一是 MIME 类型,.m3u8必须返回application/vnd.apple.mpegurl,.ts必须是video/mp2t,类型不对某些播放器会拒绝加载。nginx 里可以这样补:
location ~ \.m3u8$ { types { application/vnd.apple.mpegurl m3u8; } add_header Access-Control-Allow-Origin *; add_header Cache-Control no-cache; } location ~ \.ts$ { types { video/mp2t ts; } add_header Cache-Control max-age=31536000; }第二是跨域,如果你的播放页和视频文件不同域,必须加 CORS 头,否则 hls.js 拉分片会被浏览器拦掉。index.m3u8建议不缓存或者短缓存,ts 分片可以设置很长的缓存时间,因为它们的内容一旦生成就不会变。
网页端播放用 hls.js 是最常见的方案,Vue 或者 React 项目里都是引入这个库,然后hls.loadSource('hls/index.m3u8')。Safari 因为原生支持 HLS,不需要额外库,但为了统一行为,多数项目还是会走 hls.js 加降级判断。
6. 转换失败的排查实录
6.1 报错信息该怎么读
FFmpeg 的报错信息其实很有信息量,只是长得吓人。我总结了一个读法顺序。
先看最后一行的具体错误。比如Invalid data found when processing input表示输入根本没法解析,问题在源文件;Unknown encoder 'libx264'是构建里没这个编码器,问题在环境;Protocol not on whitelist 'crypto'是白名单缺项,问题在参数。
再看中间的流信息。ffmpeg 会先打印一行Input #0, hls, from 'xxx',后面跟着Stream #0:0: Video: h264 ...这样的描述。如果这一行里出现的是你意料之外的编码格式,比如你以为是 H.264 结果是 MPEG-4 Part 2,那后面-c copy到 mp4 可能就播不了。这个信息比报错本身更有用。
最后看警告。带[hls @ 0x...]前缀的警告通常指向清单文件的问题,比如分片缺失、时长标注不一致、序列号跳变。这些警告不一定导致失败,但值得看一眼。
6.2 音画不同步与时间戳问题
这是最烦人的一类问题,因为命令跑成功了,输出文件却不对。常见原因有三种。
第一种是分片的时间戳不连续。有些录制工具生成的分片,每段都从 0 开始计时,合并起来时间轴就重复了。处理办法是让 ffmpeg 重新生成时间戳:
ffmpeg -fflags +genpts -i index.m3u8 -c copy output.mp4第二种是负时间戳。某些 ts 的第一帧时间戳是负值,mp4 容器不接受,ffmpeg 会报Non-monotonous DTS或者直接丢掉开头几帧。加这个参数处理:
-avoid_negative_ts make_zero第三种是音频采样率不一致导致的累积漂移。音频轨道如果有轻微的速度偏差,一小时下来能差出好几秒。这种情况只能重编码音频,让它按视频时间轴重新对齐:
-c:v copy -c:a aac -af aresample=async=1:first_pts=0顺带说一句,老教程里常见的-async 1参数在新版本里已经废弃了,用aresample滤镜替代,看到旧写法别照抄。
6.3 常见问题速查表
| 现象 | 可能原因 | 处理办法 |
|---|---|---|
Unknown encoder 'libx264' | 构建未启用该编码器 | 换-c:v h264_nvenc或重新安装完整构建 |
Protocol 'crypto' not on whitelist | 缺少协议白名单 | 加-protocol_whitelist file,http,https,tcp,tls,crypto |
Invalid data found | 源文件损坏或加密密钥错误 | 检查密钥是否正好 16 字节 |
| 输出只有声音没有画面 | 视频编码不被 mp4 支持 | 改-c:v libx264重编码 |
| 合并后顺序错乱 | 文件名字典序不等于数字序 | 命名补零或用自然排序 |
| 切片后只有前几个分片能播 | -hls_list_size默认值 5 | 改成-hls_list_size 0 |
| 网页播放器一直转圈 | MIME 类型或 CORS 不对 | 配置application/vnd.apple.mpegurl和跨域头 |
| 码率切换时花屏 | 各变体关键帧不对齐 | 强制-g、-keyint_min、-sc_threshold 0 |
| 进度条不显示总时长 | 缺#EXT-X-ENDLIST | 加-hls_playlist_type vod |
| 网络源 403 | 缺 Referer 或 User-Agent | 用-headers补齐 |
| 输出文件 0 字节 | 输入输出同路径冲突 | 换输出目录或文件名 |
6.4 几个不影响功能但很烦人的坑
最后说几个"能跑通但用着别扭"的问题。
mp4 损坏。有时候文件能识别但播到一半卡死,通常是moov盒子或者索引表出了问题。可以先试试ffmpeg -i broken.mp4 -c copy fixed.mp4,很多时候重新封装一遍就好了,因为 ffmpeg 会重建索引。如果连 ffprobe 都读不出来,那就得用十六进制工具去看文件头尾的原子结构,找ftyp和moov的位置,这种修复属于另一个话题了,成功率也不高,重要文件平时多做备份比事后修划算。
分片数量太多。一个两小时的视频按 2 秒切片会产生 3600 个文件,目录打开都要卡半天,上传到对象存储也是几千个请求。这种时候要么把-hls_time调大到 6 到 10 秒,要么用-hls_flags single_file把所有分片打进一个文件。
文件命名带空格或中文。命令行里会被拆成多个参数,脚本里更是灾难。切片的输出目录和文件名统一用英文加下划线,这个问题就不存在了。
校验不能只看大小。有人合并完看输出文件有 800MB 就以为成功了,结果播到某个位置就断。老老实实用 ffprobe 看时长,再挑几个时间点实际播一下,比看文件大小靠谱得多。
7. 用 Python 把重复劳动脚本化
7.1 先探测再决策
批量处理时最忌讳不管三七二十一直接跑命令,因为素材情况千差万别。我的做法是先探测,再根据探测结果决定参数。ffprobe 支持 JSON 输出,用 Python 解析非常方便:
import json import subprocess def probe(path): cmd = [ "ffprobe", "-v", "error", "-print_format", "json", "-show_format", "-show_streams", path, ] result = subprocess.run(cmd, capture_output=True, text=True, check=True) return json.loads(result.stdout) info = probe("index.m3u8") for s in info["streams"]: print(s["codec_type"], s.get("codec_name"), s.get("width"), s.get("height")) print("duration:", info["format"].get("duration"))拿到这些信息之后就能判断了:如果是 h264 加 aac,走-c copy;如果是 hevc 而目标播放器不支持,就安排重编码;如果音频是 ac3,转 aac 提高兼容性。这个判断逻辑写一次,后面所有素材都能自动处理。
7.2 批量合并的完整脚本
下面这个脚本我用了很久,处理目录结构为input/<批次>/index.m3u8的素材,输出到output/<批次>.mp4,已经合并过的自动跳过:
import subprocess from pathlib import Path SRC = Path("input") DST = Path("output") DST.mkdir(exist_ok=True) for case in sorted(p for p in SRC.iterdir() if p.is_dir()): target = DST / f"{case.name}.mp4" if target.exists() and target.stat().st_size > 0: print(f"[skip] {case.name}") continue cmd = [ "ffmpeg", "-hide_banner", "-loglevel", "error", "-allowed_extensions", "ALL", "-i", str(case / "index.m3u8"), "-c", "copy", "-movflags", "+faststart", "-y", str(target), ] print(f"[run ] {case.name}") r = subprocess.run(cmd, capture_output=True, text=True) if r.returncode != 0: print(f"[fail] {case.name}: {r.stderr.strip()[-300:]}")几个细节值得说一下。-y表示覆盖已有文件,不加的话 ffmpeg 会卡在交互式询问上,脚本就挂住了,这是自动化里非常经典的一个坑。capture_output=True把输出收进变量,只打印最后 300 个字符的错误信息,屏幕上不会被刷屏。跳过逻辑用文件存在加大小大于 0 双重判断,因为有时候中断会留下 0 字节文件。
7.3 断点续跑与日志习惯
批量跑几百个任务,中途失败是常态。我的经验是每一行状态都写到日志文件里,跑完之后 grep 一下就能找到失败的:
import logging logging.basicConfig( filename="convert.log", level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s", ) logging.info("task start: %s", case.name)脚本重跑的时候因为有了跳过逻辑,已经成功的不会重做,失败的会自动重试,这样反复跑几次基本能把全部处理完。比一次性跑完更好的一点是,机器负载也低,不会因为长时间满负载导致其他服务受影响。
如果用asyncio并发跑多个转换任务,记得控制并发数。FFmpeg 的-c copy模式本身资源占用很低,并发 4 到 8 个没问题;但如果是重编码,每个进程都能吃满多核,并发 2 个就已经让 CPU 跑满了,再往上加只会互相拖慢。我的做法是根据是不是重编码来决定并发数,copy 模式给 8,重编码给 2。
8. 最后分享几个我自己的使用习惯
写到这里基本把两个方向的转换都覆盖了。我自己的几个固定习惯分享一下,可能对你有用。
一是任何命令先在小片段上试。拿-t 30截前 30 秒跑一遍,确认输出没问题再处理完整文件,能省掉大量等待时间。尤其是多码率切片这种命令又长又容易错的,试跑一次能提前发现参数写错。
二是输出文件永远另存。不管多简单的合并,都写到新文件名里,原素材一律不动。我见过太多人-y直接覆盖了源文件,回头想重来发现素材没了。
三是保留一份原始清单。转换完 m3u8 之后,把index.m3u8和几个分片一起归档,万一以后需要重新生成不同参数的版本,不用再去重新下载。体积不大,但能救急。
四是参数写进脚本而不是记在脑子里。每个项目建一个.sh或者.py,把用着顺手的参数固化下来,下次直接改输入路径就行。我现在有十几个这样的脚本,覆盖不同分辨率和不同平台的切片需求,比每次查文档快得多。
转换这件事本身没什么玄学,核心就三件事:分清楚是复制流还是重编码,参数搞清楚每个是干什么的,出问题知道从哪一行报错入手。剩下的都是熟练度问题。