
做流媒体这块也有几年了从早期的RTMP推流到后来的WebRTC低延迟轮番折腾下来生产环境里用得最稳、维护成本最低的反而是HLS这套组合拳。尤其是当需求里同时出现直播、点播、版权保护和网络自适应这几个词的时候HLSM3U8基本就是标准答案。今天我想把这条链路上的关键环节完整拆一遍视频切片怎么切才不会翻车AES加密怎么加才靠谱多码流自适应怎么组织才算专业。文章偏实操适合正在做视频服务、或者被HLS播放问题折磨过的人。1. HLS这套方案到底解决了哪些实际问题先聊清楚HLS为什么能活到现在。HLS全称HTTP Live Streaming苹果在2009年提出设计初衷是把连续的视频流切成一个个独立的小文件然后通过一个文本索引让播放器按顺序拉取。这么多年过去了它的核心思路几乎没有变过用纯HTTP静态文件协议去承载流媒体传输。这和RTMP有本质区别。RTMP需要长连接服务器要维护会话状态CDN边缘节点要处理大量并发连接一个节点挂了用户就断流。而HLS的每个切片是一个独立文件CDN可以把它们当成普通静态资源来缓存和分发。用户看视频的过程本质上是持续下载一个个小文件这个行为静态加速节点天生就擅长。所以HLS对CDN极其友好这是它在分发规模上碾压RTMP的根本原因。对于点播场景HLS的好处更加直观。一个两小时的电影转成HLS之后就是几千个TS切片加一个M3U8索引。用户拖进度条播放器直接计算目标时间点对应哪个切片然后发起HTTP Range请求。因为每个切片可以独立缓存、独立分发冷门片段和热门片段在CDN上是完全独立的热度单位不会因为某一秒的内容特别火而把整个文件搞得很被动。再说直播。HLS直播虽然在延迟上比不过WebRTC但胜在稳定和跨端。它不需要客户端维持长连接播放器每隔几秒去拉最新切片就行。网络抖动的时候播放器可以通过缓冲几个切片来平滑波动不会像RTMP那样因为一点卡顿就触发重连。而且HLS原生支持我们后面要讲的多码流ABR同一场直播同时输出高清、标清、流畅三路播放器根据网速动态切换这套逻辑在RTMP里实现起来非常痛苦。还有一个容易被忽略的点HLS是文本索引加二进制切片的结构天然适合做加密防护。虽然它不是DRM级别的保护但AES-128加密后直接抓走TS文件是没法播放的这给内容方多了一道防护。后面我会专门讲这一块。一句话总结HLS适合要规模、要稳定、要跨端适配、内容还需要基础保护的所有场景。它唯一的短板是延迟标准HLS通常在10到30秒不等不过低延迟HLS的出现已经把这条线拉到了2到5秒后面在直播章节细说。2. M3U8索引文件解剖一条流是怎么被索引出来的很多人把M3U8叫m3u8索引这个名字其实很准确。它本质是一份UTF-8编码的播放列表里面每一行都是一个标签或者一个资源地址。播放器拿到这个文件之后先读标签了解流的属性再按顺序去拉取资源。下面我用几段实际例子带大家读懂这个文件。2.1 最简单的点播切片列表长什么样这是一份典型VOD点播播放列表#EXTM3U #EXT-X-VERSION:3 #EXT-X-TARGETDURATION:6 #EXT-X-PLAYLIST-TYPE:VOD #EXT-X-MEDIA-SEQUENCE:0 #EXTINF:6.00000, segment_0000.ts #EXTINF:6.00000, segment_0001.ts #EXTINF:6.00000, segment_0002.ts #EXT-X-ENDLIST逐行解释一下#EXTM3U文件头声明这是M3U格式所有合法播放列表第一行都必须是它。#EXT-X-VERSION:3协议版本号。版本3支持浮点数的EXTINF时长版本7支持LL-HLS的EXT-X-PART等标签。兼容性考虑通常用版本3就好。#EXT-X-TARGETDURATION:6声明切片的最大时长。播放器用这个值来估算缓冲时间所有EXTINF的时长都不能超过这个值否则播放器可能报错。#EXT-X-PLAYLIST-TYPE:VOD告诉播放器这是完整点播文件不会再有切片追加进来可以放心支持拖拽。#EXT-X-MEDIA-SEQUENCE:0序列号起点。直播流中这个数字会不断增长表示当前列表第一个切片的序号。#EXTINF:6.00000,后面紧跟的TS文件的时长单位秒。切片文件名可以是相对路径也可以是绝对URL播放器会基于M3U8所在的地址做拼接解析。#EXT-X-ENDLIST播放列表结束标记。点播流必须有直播流不能有。这份列表的阅读逻辑就是从第0个切片开始按顺序下载每个切片时长6秒直到ENDLIST为止。2.2 加密流里多出来的EXT-X-KEY标签如果切片做了AES-128加密播放列表里会多出这样一行#EXT-X-KEY:METHODAES-128,URIhttps://cdn.example.com/keys/key.bin,IV0x00000000000000000000000000000000它的位置在第一个被加密的切片条目之前表示从这一行之后的所有切片都用这个密钥解密。播放器加载到这个标签后会先去URI下载16字节的密钥文件然后结合IV对每个TS文件做AES-128-CBC解密。注意如果密钥URI是相对路径比如keys/key.bin播放器会以M3U8所在URL为基准拼接完整地址。这个看似不起眼的细节是后面很多解密失败转换失败问题的根源后面排错章节会细讲。2.3 直播播放列表会滚动的索引直播切片不会一次性全部生成而是像流水线一样持续追加。播放器看直播时拉取到的队列表是动态的#EXTM3U #EXT-X-VERSION:3 #EXT-X-TARGETDURATION:6 #EXT-X-MEDIA-SEQUENCE:100 #EXTINF:6.00000, segment_100.ts #EXTINF:6.00000, segment_101.ts #EXTINF:6.00000, segment_102.ts注意这里没有#EXT-X-PLAYLIST-TYPE:VOD也没有#EXT-X-ENDLIST。#EXT-X-MEDIA-SEQUENCE:100告诉播放器当前列表第一个切片是第100号。播放器每次刷新列表时发现新序列号就继续往下拉。旧的切片会被服务器逐步移出列表这就是滑动窗口。理解这一点对处理直播很重要播放器不会去列表之外的切片文件。如果播放器因为某种原因落后太多了服务器已经删掉了它所需要的旧切片播放器要么追帧、要么重新加载列表严重的直接卡死。这就是直播和点播在HLS语义下最大的区别。2.4 多码流主播放列表索引套索引多码流自适应时我们面对的不再是一条播放列表而是一个主列表Master Playlist加上若干个码率子列表。播放器先加载主列表根据网速选择子列表加载。#EXTM3U #EXT-X-STREAM-INF:BANDWIDTH4500000,RESOLUTION1920x1080,CODECSavc1.64001f,mp4a.40.2 stream_1080p/index.m3u8 #EXT-X-STREAM-INF:BANDWIDTH2500000,RESOLUTION1280x720,CODECSavc1.4d001f,mp4a.40.2 stream_720p/index.m3u8 #EXT-X-STREAM-INF:BANDWIDTH1000000,RESOLUTION854x480,CODECSavc1.4d001e,mp4a.40.2 stream_480p/index.m3u8其中BANDWIDTH是包含音视频在内的峰值带宽估值单位bpsRESOLUTION是分辨率CODECS是编解码器标识字符串。播放器根据这些元数据做阶梯选择比如网速快就切高码率档网速差就自动降档。如果流里有单独的外挂字幕或备选音频还会出现#EXT-X-MEDIA标签去做音轨绑定这个在基础HLS里用得不多先不展开。3. 视频切片实操FFmpeg怎么切才不容易翻车播放列表懂了接下来就是怎么生成一份靠谱的切片。命令本身不复杂但坑特别多尤其是切片边界不落在关键帧上这个问题能坑掉一大半新手。3.1 为什么切片边界必须对齐关键帧HLS的TS切片要做到每个切片都能独立解码播放。TS文件开头通常是第一个关键帧IDR帧开始的GOPGroup of Pictures。播放器拉到一个切片后直接从这个片段的起始关键帧开始解码不依赖上一个切片。如果切片边界恰好切在非关键帧上播放器拿到一个半截GOP画面就无法解码表现出来就是切入时间点黑屏、花屏或者跳动。FFmpeg在切片时会自动把切片边界对齐到关键帧但前提是编码器产出的关键帧间隔不能太散。这就涉及两个参数的最小配合-flags cgop -g 48 -keyint_min 48-g 48表示每48帧一个关键帧-keyint_min 48确保关键帧间隔不会小于48帧。如果视频是24fps正好2秒一个GOP30fps就是1.6秒一个GOP。设置-hls_time 4时FFmpeg会以4秒为目标时长寻找最近的关键帧作为切片边界实际切出来的片段可能是4.0秒、也可能是4.05秒这都是正常的。3.2 点播切片的标准命令对于VOD点播一份比较稳妥的命令是这样的ffmpeg -i input.mp4 \ -c:v libx264 -crf 20 -preset medium \ -c:a aac -b:a 128k -ac 2 \ -flags cgop -g 48 -keyint_min 48 \ -hls_time 4 -hls_list_size 0 \ -hls_playlist_type vod \ -hls_segment_filename segment_%04d.ts \ index.m3u8逐个参数说明-crf 20视频质量因子数字越小质量越高、文件越大。VOD点播我一般建议18到22之间20属于画质和体积比较平衡的选择。-hls_time 4目标切片时长4秒。为什么不是2秒也不是10秒2秒会导致切片数量膨胀、请求量翻倍CDN压力大而且播放器频繁请求列表也费电10秒会让直播延迟更高点播拖拽精度也变差。4到6秒是绝大多数流媒体平台的选择。-hls_list_size 0生成的播放列表包含所有切片。如果这里不设成0默认只会保留最近的5个切片点播流就没法完整播放了。-hls_playlist_type vod让播放列表带上#EXT-X-PLAYLIST-TYPE:VOD和#EXT-X-ENDLIST标记。-hls_segment_filename segment_%04d.ts切片文件名模板%04d就是四位数字补零。如果源文件本身已经是H.264AAC编码切片的转码环节其实可以省略直接用-c copy纯复制流速度极快ffmpeg -i input.mp4 -c copy \ -hls_time 4 -hls_list_size 0 \ -hls_playlist_type vod \ -hls_segment_filename segment_%04d.ts \ index.m3u8但要注意-c copy必须确保源文件的GOP天然对齐否则切出来的切片在拖拽时容易出问题。3.3 生产环境里的文件命名和时间戳策略小规模自用怎么命名都行一旦上了CDN或者客户端会缓存切片的场景我强烈建议切片文件名中不要只有序号而是加上时间戳或者内容哈希前缀。比如segment_1690000000_0000.ts。原因有两个第一CDN回源排查时看文件名就知道这切片是什么时候生成的第二如果你需要把切片重新组装成视频或者做离线打包带时间戳的文件排序不会乱。而且如果切片属于同一个流的多个版本建议用不同的目录名来隔离避免文件名冲突导致CDN缓存串流。这里还要注意一点如果使用-hls_segment_filename时路径中带有子目录比如stream_1080p/segment_%04d.tsFFmpeg会自动创建子目录吗实测不一定。建议先手动mkdir好目录再跑命令否则部分版本会直接报错。3.4 切片时长和实际时长的偏差你可能会发现-hls_time 4切出来的切片EXTINF写的是4.00000但实际上有的切片可能是4.05秒有的可能是3.94秒。这正常。因为切片以GOP边界对齐为目标目标时长只是参考值。但如果出现某个切片明显异常比如EXTINF是8秒远超#EXT-X-TARGETDURATION那就要检查编码器GOP设置是否失效了。常见原因是源视频自己带着极大的GOP而-c copy模式没有重新编码FFmpeg只能按照源GOP边界切片源GOP是8秒就只能切出8秒的块TARGETDURATION也会被拉大到8播放器缓冲量按最大值算延迟就上去了。所以如果是直播流或者对延迟敏感的场景宁可多花CPU重新转码也不要图省事直接copy。GOP对齐这事转码是唯一稳妥的解法。4. AES-128加密给TS切片加一把正经锁讲完切片来说说加密。HLS的AES-128加密方案很直接每个TS切片整个文件做AES-128-CBC加密密钥是一个16字节的二进制文件播放列表里通过EXT-X-KEY标签告诉播放器密钥的获取地址。4.1 HLS加密为什么选AES-128-CBC而不是其他算法AES-128-CBC是HLS协议最早支持的加密方式也是所有播放器、所有服务端库兼容性最好的一套。CBC模式会把每个切片分成若干16字节块前一个块的密文参与下一个块的加密形成链式依赖。切片开头用IV初始化向量作为第一个块的前一个密文。关于IV协议允许两种做法一是直接在EXT-X-KEY标签里显式写IV0x...二是省略IV这时默认取#EXT-X-MEDIA-SEQUENCE的值作为IV。显式写IV更可控推荐生产环境使用。IV是16字节通常写作32位十六进制字符串。有一点要明确每个TS切片独立加密密钥是同一个但IV通常会随切片序号变化。这样即使两个切片内容相同密文也不同可以防止有人通过比对密文推测内容模式。如果所有切片都用同一个IV安全性会明显下降。4.2 FFmpeg做AES-128加密的完整流程FFmpeg的HLS muxer原生支持加密准备工作需要两个文件密钥文件16字节随机数和密钥信息文件key info file。先生成密钥openssl rand 16 key.key密钥文件必须是恰好16字节的二进制文件。注意有些初学者用文本编辑器写一个16个字符的字符串当key这是不对的。AES-128密钥要求128位即16字节二进制openssl rand 16生成的才是合法密钥。然后创建密钥信息文件key_info.txt格式固定三行key.key https://cdn.example.com/keys/key.key 0x00000000000000000000000000000000第一行是本地密钥文件的路径FFmpeg进程读取用第二行是播放器访问密钥的URL会写进M3U8的EXT-X-KEY标签第三行是IV的十六进制形式可以省略。如果你的环境不能确定IV建议还是写显式IV避免某些非主流播放器在IV推导上出现问题。然后执行切片加密ffmpeg -i input.mp4 \ -c:v libx264 -crf 20 -preset medium \ -c:a aac -b:a 128k \ -flags cgop -g 48 -keyint_min 48 \ -hls_time 4 -hls_list_size 0 \ -hls_playlist_type vod \ -hls_key_info_file key_info.txt \ -hls_segment_filename segment_%04d.ts \ index.m3u8跑完之后打开M3U8会在第一个切片前看到类似这样的标签#EXT-X-KEY:METHODAES-128,URIhttps://cdn.example.com/keys/key.key,IV0x00000000000000000000000000000000播放器遇到这个标签之后后续所有TS都会先下载密钥再解密播放。4.3 加密部署时的实际坑点加密这条路上有几个坑我踩过也帮别人排查过这里集中列一下。坑一密钥文件访问权限和CORS问题。播放器是浏览器里的hls.js时密钥文件的请求同样受CORS限制。如果你的M3U8、TS都在cdn.example.com而密钥文件放在keys.example.com没有给keys.example.com配置Access-Control-Allow-Origin浏览器会直接拦截密钥请求表现为能加载M3U8但视频一直黑屏。解决方式有两种要么把密钥文件和TS放到同一个域名下要么给密钥目录配置CORS头。CDN边缘节点通常不能直接设置需要在源站配置。坑二密钥URI的路径是相对还是绝对。EXT-X-KEY里的URI写相对路径时播放器以M3U8所在URL为基础拼接。如果你的M3U8在/video/stream/index.m3u8URI写../keys/key.key就会拼成/video/keys/key.key而不是你可能预期的/keys/key.key。一旦拼错播放器就下载不到密钥。在FFmpeg的key_info第二行我推荐直接写完整的绝对URL虽然部署时稍微麻烦点换域名就得改但能大幅降低排错难度。坑三-c copy模式下加密效率高但小心不标准的源流。-c copy切加密流在FFmpeg里是支持的速度飞快。但如果源TS里带有多音轨、多字幕轨或者非常规PIDcopy切出来的切片在部分播放器上可能无法解密。我遇到过HLS播放器解密后画面正常但没声音的case最后发现是音轨PID和PAT/PMT表不匹配导致的。这种情况重新转码AAC一般能解决。坑四EXT-X-KEY放在了错误的位置。EXT-X-KEY标签只对它之后的所有切片生效。如果你在M3U8中间某处又插入了一个新的EXT-X-KEY那么它只对后面的切片生效。如果这个新标签的URI写错后面所有切片都会播放失败。调试时看到一个流前面几秒能播、后面突然黑屏优先检查列表中间是不是混入了第二个EXT-X-KEY。5. 多码流自适应从单路流到完整的ABR阶梯多码流自适应ABR的目标很简单同一个视频内容准备多路不同码率/分辨率的切片让播放器根据网络带宽动态选择最合适的一路。用户网络好就看1080p网络差自动降到480p整个过程不能在画面上出现明显中断。5.1 为什么需要多码流而不是单码流转码单码流转码相当于一刀切为了照顾最差网络只能压得很低好的设备也看不到高清画面照顾最好网络低配用户就不断卡顿。多码流是行业标配的解法本质上是用多路转码的计算成本换取每个用户都能在自己网络条件下获得尽量好的体验。另外从成本角度说转码计算的边际成本并不是线性增长的。同一个源同时产三路流的CPU开销小于分别转三次的开销因为视频解码只需要做一次三路主要是编码和滤镜的差异。这也是FFmpeg单命令多路输出能够节省资源的原因。5.2 用FFmpeg生成多码率切片的标准姿势最省事、最不容易出错的做法是分别跑多路FFmpeg每一路生成一个子目录ffmpeg -i input.mp4 \ -vf scale1920:1080 -b:v 4500k -maxrate 5400k -bufsize 8000k \ -c:v libx264 -g 48 -keyint_min 48 \ -c:a aac -b:a 128k \ -hls_time 4 -hls_list_size 0 -hls_playlist_type vod \ -hls_segment_filename stream_1080p/segment_%04d.ts \ stream_1080p/index.m3u8 ffmpeg -i input.mp4 \ -vf scale1280:720 -b:v 2500k -maxrate 3000k -bufsize 4500k \ -c:v libx264 -g 48 -keyint_min 48 \ -c:a aac -b:a 128k \ -hls_time 4 -hls_list_size 0 -hls_playlist_type vod \ -hls_segment_filename stream_720p/segment_%04d.ts \ stream_720p/index.m3u8 ffmpeg -i input.mp4 \ -vf scale854:480 -b:v 1000k -maxrate 1200k -bufsize 2000k \ -c:v libx264 -g 48 -keyint_min 48 \ -c:a aac -b:a 96k \ -hls_time 4 -hls_list_size 0 -hls_playlist_type vod \ -hls_segment_filename stream_480p/segment_%04d.ts \ stream_480p/index.m3u8跑完之后手动写一个主播放列表#EXTM3U #EXT-X-STREAM-INF:BANDWIDTH5000000,AVERAGE-BANDWIDTH4500000,RESOLUTION1920x1080,FRAME-RATE25,CODECSavc1.640028,mp4a.40.2 stream_1080p/index.m3u8 #EXT-X-STREAM-INF:BANDWIDTH2800000,AVERAGE-BANDWIDTH2500000,RESOLUTION1280x720,FRAME-RATE25,CODECSavc1.4d001f,mp4a.40.2 stream_720p/index.m3u8 #EXT-X-STREAM-INF:BANDWIDTH1300000,AVERAGE-BANDWIDTH1000000,RESOLUTION854x480,FRAME-RATE25,CODECSavc1.4d001e,mp4a.40.2 stream_480p/index.m3u8把这份主播放列表命名为master.m3u8放在根目录播放器加载它就行。BANDWIDTH建议比实际编码码率留20%的余量因为网络请求还有TS封装开销和TCP开销写得太紧播放器会频繁切换。5.3 三路流的切片时间必须严格对齐多码流ABR切换的基础是所有码率档位的切片时长必须严格对齐。1080p的第5个切片和480p的第5个切片在时间轴上必须覆盖相同的时间范围。这样播放器从1080p切到480p时只需要从下一个切片开始切换不需要重新对齐buffer。怎样保证时间对齐FFmpeg在-g和-keyint_min相同的情况下多路转码的GOP边界天然对齐因为源帧率相同、GOP相同、切片的GOP边界相同。只要都用了-flags cgop -g 48 -keyint_min 48切片时长基本一致。这里有个很容易踩的坑如果三路命令中有一路忘了加-g那一路的GOP间隔和另外两路不一致切出来的切片边界就对不上。播放器切换时会出现跳帧、快进、卡顿甚至重复播放一小段。所以在批量生成多码率时GOP参数必须完全一致。另外如果源视频是可变帧率VFR切片时长会漂移多路流的对齐也可能出现偏差。稳妥的做法是先统一转换成恒定帧率CFR比如加-r 25或者-fps_mode cfr。5.4 播放器端的自适应选择逻辑在浏览器端hls.js是目前最成熟的HLS播放方案。Vue项目里集成很简单import Hls from hls.js; export function playM3u8(videoElement, url) { if (videoElement.canPlayType(application/vnd.apple.mpegurl)) { // Safari 原生支持 HLS videoElement.src url; } else if (Hls.isSupported()) { const hls new Hls({ maxBufferLength: 30, maxMaxBufferLength: 60, startLevel: -1 // -1 表示自动选择初始码率 }); hls.loadSource(url); hls.attachMedia(videoElement); hls.on(Hls.Events.ERROR, (event, data) { if (data.fatal) { switch (data.type) { case Hls.ErrorTypes.NETWORK_ERROR: hls.startLoad(); break; case Hls.ErrorTypes.MEDIA_ERROR: hls.recoverMediaError(); break; default: hls.destroy(); } } }); } }hls.js默认会通过带宽估算自动选择码率startLevel: -1表示首次加载时自动。它内部会估算下行带宽并在每个分段下载后重新评估是否切换。你可以监听LEVEL_SWITCHED事件来感知切换动作hls.on(Hls.Events.LEVEL_SWITCHED, (event, data) { const level hls.levels[data.level]; console.log(切换到码率档:, level.height, level.bitrate); });实测中hls.js的ABR表现比较激进网络稍微抖动就降档这在移动端尤为明显。如果产品上希望减少频繁切换可以在Hls构造函数里把abrEwmaDefaultEstimate调高一些并且把capLevelToPlayerSize设为true让它在小屏设备上不拉高清流节省流量。6. 直播场景下的HLS滑动窗口、延迟和播放器配合HLS直播和点播的切片逻辑底层相同但播放列表管理完全不同。直播场景下切片是持续生成的播放列表会不断追加新条目、移除旧条目播放器必须理解这种动态索引才能流畅追播。6.1 点播和直播在M3U8上的关键差异直接对比一下项目点播 VOD直播 LIVE切片总数固定不变化持续增加是否带ENDLIST有无PLAYLIST-TYPEVOD可省略或EVENTMEDIA-SEQUENCE固定为0随窗口滑动持续增长播放器拖拽范围整个文件仅当前窗口内典型切片保留永久根据窗口大小播放器判断一个M3U8是不是直播主要看有没有#EXT-X-ENDLIST。有就是点播没有就是直播。#EXT-X-PLAYLIST-TYPE:EVENT是一个中间状态表示当前还是在直播但列表可能会保留所有历史切片直到直播结束补上ENDLIST变成可完整回看的点播文件。6.2 滑动窗口是什么为什么要滑动服务器如果无限保留切片磁盘会爆。所以直播场景通常只保留最近N个切片比如最近2分钟。这意味着播放器的可回看范围只有这2分钟。播放器每次重新加载M3U8时看到MEDIA-SEQUENCE增加、列表头部的旧切片被移除就明白自己已经落后了需要追上去。这种机制下有一个我踩过很多次的坑如果播放器暂停时间过长比如用户离开电脑10分钟回来之后它继续去拉之前记录的旧切片序号但服务器早就删掉那些切片了返回404播放器就可能一直卡住。稳妥的做法是在直播播放器里监听网络错误或者缓冲超时一旦发现切片404就主动重新加载M3U8并跳到最新位置。hls.js在NETWORK_ERROR时默认会startLoad但恢复速度不一定理想移动端弱网时最好自己加一层逻辑。6.3 HLS直播延迟怎么降标准HLS延迟高的原因很好理解播放器至少要缓冲2到3个TS切片。假设切片4秒播放器拉完第一个切片之后并不会立即播放而是要等后续两个切片就绪防止网络抖动导致中断。这样一算10秒以上的延迟是跑不掉的。延迟敏感场景有两个主流解法。第一是走LL-HLSLow-Latency HLS切片进一步拆分成更小的part播放器可以只等part就开播而不必等整个TS。配合HTTP/2分帧延迟能压到2到5秒。FFmpeg 5.0以上和hls.js 1.2以上都支持LL-HLS标签具体命令需要加-hls_playlist_type event -hls_flags independent_segments并用-hls_segment_type fmp4配合-hls_fmp4_init_filename。当然这套方案对CDN和播放器支持要求都比较高。第二是走WebRTC推流、边缘转HLS再分发。这其实是一种混合架构主播端用WebRTC低延迟上行服务端拉流后切片为HLS观众端仍然用常规播放器。它解决的是主播到服务器的上行延迟下行观众延迟还是要看HLS本身的窗口。这类架构适合互动直播场景。6.4 直播转点播的落地方案很多产品希望直播结束后能回看这时候EVENT类型的播放列表就特别好用。直播中它持续追加切片直播结束后我们在列表末尾补上#EXT-X-ENDLIST并把PLAYLIST-TYPE改成VOD它就变成了一份完整的点播列表。所有历史切片如果之前一直保留着观众就能从头开始拖拽回看。实际生产里我通常把切片长期保存到对象存储直播结束后由服务端生成一个永久M3U8。注意原始M3U8里的MEDIA-SEQUENCE可能不从0开始补ENDLIST时不需要改序号播放器自己会从列表定位。如果你希望回看的进度条基准从0开始可以用FFmpeg重新处理一遍列表ffmpeg -i live_index.m3u8 -c copy -hls_list_size 0 -hls_playlist_type vod vod_output.m3u8但这种方式只能重写播放列表不能修改源切片序号。所以更干净的做法是直播切片时就让MEDIA-SEQUENCE从0开始窗口足够大结束后直接复用。7. 排错笔记从转MP4失败到Vue播放黑屏的排查链路HLS链路涉及的环节多问题表象却高度相似要么是播放黑屏要么是转换失败要么是播着播着卡住。这里我整理一张排查表然后挑几个高频case展开。7.1 高频问题速查表症状可能原因优先排查点M3U8能加载但一直黑屏密钥下载失败 / CORS拦截 / 切片跨域浏览器的Network面板看密钥请求是否200播放几秒后花屏或跳帧切片边界未对齐关键帧检查-g和-keyint_min重新转码只有声音没有画面视频编码为H.265或非标准H.264换用H.264 Baseline/Main编码ffmpeg转MP4失败密钥无法访问 / 路径错误 / ts损坏用ffprobe检查切片直播暂停后回来卡死播放器还指向已删除的旧切片重新加载列表跳到最新MEDIA-SEQUENCEVue里播放正常但报跨域错服务器未配置CORS头给静态目录加Access-Control-Allow-Origin7.2 m3u8视频转换失败的完整排查链路这个热搜词出现频率很高。很多人拿网上现成的命令ffmpeg -i https://example.com/stream/index.m3u8 -c copy output.mp4结果报错或者产出的mp4只有几KB。排查顺序我建议这样走第一步先直接用浏览器或者curl访问M3U8确认索引文件本身可读。如果返回404或者403问题出在URL鉴权或防盗链上需要带上Referer、Cookie或签名参数。第二步打开M3U8看是否有EXT-X-KEY标签。如果有说明流是AES-128加密的FFmpeg能自动解密但前提是密钥地址可访问。用curl单独访问一次密钥URI确认返回的是16字节二进制。如果密钥也是404或者被WAF拦截ffmpeg必然转换失败。第三步确认切片路径拼写。M3U8里的相对路径如果解析错误FFmpeg会尝试去错误地址拉TS。建议开发阶段把M3U8、密钥、TS统一放在同一目录避免路径解析的各种姿态。第四步看错误日志末尾有没有类似Invalid data found when processing input的信息。如果TS本身损坏或者源流是H.265编码HEVC而你的FFmpeg编译版本不带对应解码器也会失败。这时候要么升级FFmpeg要么换用-c copy以外的转码方式ffmpeg -i index.m3u8 -c:v libx264 -c:a aac output.mp47.3 加密流转MP4失败和密钥策略的关系很多加密HLS转MP4失败的真正原因是密钥文件的访问策略。有些平台为了安全生成的密钥URI是带时间戳签名的比如#EXT-X-KEY:METHODAES-128,URIhttps://cdn.example.com/key?tokenxxxxexpires1699999999这种URL只在有限时间内有效。如果你在切片生成几小时后才去转码密钥URL早过期了FFmpeg下载密钥失败转换当然失败。这也是为什么一个HLS下载只能在前次下载120分钟后进行这类工具限制会在网上被反复吐槽——本质就是平台做了临时密钥和防盗链工具方没法绕过授权限制只能等窗口重启。对我们做系统的人来说这个现象提醒我们密钥的过期策略要和切片生命周期匹配。如果切片已经永久存档了密钥就必须永久有效否则回看会失败。如果切片只存7天那密钥有效期设7天即可过期后同步删除切片不会出现孤岛数据。7.4 Vue播放M3U8常见的三个坑第一个坑是MSE兼容性。hls.js依赖MSEMedia Source Extensions虽然目前所有主流浏览器都支持但偶尔有用户用老版本浏览器或者某些套壳浏览器内核太旧Hls.isSupported()返回false。代码里要有降级处理Safari走原生播放其他不支持MSE的浏览器给出明确提示别让用户面对一个无法点击的播放器。第二个坑是自动播放策略。浏览器普遍限制带声音的视频自动播放Vue里初始化播放器后立即调用video.play()可能被拒绝表现为卡在首帧不播。解决方法是监听用户交互点击、滚动后再调用play或者给video加上muted属性做静音自动播放用户需要声音时再取消静音。第三个坑是跨域资源。video标签播放M3U8时TS请求发生在video元素内部但如果页面本身运行在https://a.example.comM3U8在https://b.example.comTS在https://c.example.com视频资源域必须返回允许a.example.com请求的CORS头否则hls.js会报Unable to fetch ...或者bufferStalledError。生产环境建议把视频域名和页面域名保持同根域或者统一挂到CDN下并配置好CORS。7.5 还没完播放器buffer设置和弱网表现最后补一个容易被忽略的优化点。hls.js的默认buffer参数是偏激进的它希望尽快把足够多的数据拉进buffer所以网速好时没问题网速差时可能因为缓冲数据太少而频繁触发bufferStalledError。我的经验是弱网场景下这样配置更平滑new Hls({ maxBufferLength: 30, maxMaxBufferLength: 60, maxBufferSize: 60 * 1000 * 1000, // 60MB abrEwmaFastVoD: 3, abrEwmaSlowVoD: 8, startLevel: -1, });maxBufferLength控制在30秒左右既不会让首屏太慢也不会因为缓冲太多浪费流量。abrEwmaFastVoD和abrEwmaSlowVoD是带宽估算的EMA参数调小可以让切换更敏锐调大更稳定。实测中移动端网络频繁波动时适当调大这两个值能明显减少来回切档的问题。8. 我最后想说的几句实在话视频技术这条链路坑都藏在细节里。切片时长、GOP对齐、密钥有效期、播放器的buffer策略任何一个参数设得不够讲究线上就会以各种奇怪的方式反馈给你。我的经验是先定好播放器兼容基线再倒推服务端参数。如果目标播放器是hls.jsSafari双端切片格式和加密方式就有明确的边界不需要为了兼容所有可能设备而过度设计。密钥安全这件事我想再多说一句。AES-128加密只能防住把TS文件拷走直接播的初级盗用防不了屏幕录制也防不了专业逆向。如果内容版权价值很高请在这个基础上叠加自定义协议头、动态密钥分发或者商业DRM。别把HLS加密当成万能的版权保险箱它只是把门槛从零成本拿走抬高到了需要一定技术门槛这个定位才是合理的。另外切片文件的清理策略一定要提前设计。直播切片是高速生产的一天24小时直播按4秒一个切片算就是21600个文件一周下来磁盘占用非常可观。建议用带日期的目录存储配合定时任务删除过期切片同时保存对应的M3U8作为点播索引。很多线上事故不是流拉不回来而是磁盘满了切片写不进去这种问题最难排查。最后分享一个小技巧联调阶段别用真实CDN域名先在本地起一个带CORS头的静态文件服务把所有切片、M3U8、密钥放进去用hls.js直接指向本地路径调试。这样能绕开CDN缓存和跨域干扰纯测播放逻辑。等本地验证过了再切到CDN域名到时候再踩的坑基本就只剩CDN配置了。做流媒体调试能把变量控制得越少解决问题的速度就越快。