尧图精选

FFmpeg与HLS:从零搭建跨终端的轻量视频平台实践

🕒 发布时间:2026/9/16 23:28:51 📁 来源:尧图网络
1. 为什么一个不起眼的播放页面最后做成了完整平台LunaTV这个项目严格来说不是我“规划”出来的而是被实际需求逼出来的。去年年中我手里攒了一批内部培训录像和公开课回放加起来几百条散落在不同的硬盘和网盘目录里。找某个主题的视频时要在文件夹里翻半天发给同事要看对方有没有对应播放器更别提手机上想临时看一眼根本找不到合适的入口。最初我只想写一个简单的播放页面把视频文件路径读出来、点开能播就行。但做着做着就发现如果只是把文件路径塞进网页里那跟直接发文件没有任何区别。播放器页面的背后其实藏着三个必须解决的问题。第一视频原片格式五花八门有的是.mov、有的是.mkv编码有H.264也有HEVC还有不少是MPEG-4直接丢给浏览器大概率播放不了。第二视频的标题、分类、封面、简介、时长这些信息需要一个地方存起来不然用户只能靠文件名猜内容。第三一个视频要在PC浏览器、手机、平板上都能稳定播放得有统一的流媒体协议支撑不能指望每个终端都装万能播放器。想明白这三点之后LunaTV就不再是一个播放页面了而是一条完整的视频处理流水线。源文件进库之后依次经过格式转码、HLS切片、元数据登记、封面抽取、API发布最后在各个终端的播放器里统一播放。这条链路每个环节都不是什么炫技的黑科技但把它们串在一起、跑顺、扛住真实使用压力才是这个项目真正的价值所在。这套思路适合谁参考呢如果你也面临类似的场景——公司内部培训视频库、个人收藏的公开课合集、小团队的知识沉淀系统又不想被现成的商业视频平台绑架那LunaTV的整套做法可以直接拿过去用。整个技术选型也刻意往轻量、低成本方向靠不是动辄上K8s和微服务那种重方案。技术栈先交代清楚后端我用的是 Node.js数据库选了轻量的SQLite转码和切片调用系统里的FFmpeg任务队列用Redis BullMQ播放器层用了hls.js最外层用Nginx做静态资源服务和缓存前端是个简单的 Vue 单页应用。这套组合在几百个用户以内完全够用而且哪里出了问题一个人就能排查到位不需要一个运维团队。2. FFmpeg转码与HLS切片视频能播起来的根本2.1 转码参数不追求画质极致只追求“处处能播”做视频平台的第一个坎就是转码。很多视频源文件在本地用 PotPlayer 或者 IINA 看得很流畅但浏览器打死也放不出来。原因通常是编码格式不被浏览器支持。桌面端浏览器普遍支持的视频编码是H.264和VP9但很多设备拍出来的视频是HEVC还有些老视频是MPEG-4 Part 2这些格式在某些浏览器里就是解码不了。我当时定的转码目标是所有入库视频统一转为 H.264 AAC封装成 MP4。H.264 是浏览器兼容性最好的视频编码AAC 是兼容性最好的音频编码MP4 是兼容性最好的封装格式这三者组合覆盖范围最广从老旧的 Windows 7 机器到最新款的手机都能播。我用的核心转码脚本是这一套ffmpeg -i input.mp4 \ -c:v h264 \ -profile:v high \ -level 4.1 \ -preset medium \ -crf 23 \ -c:a aac \ -b:a 128k \ -ac 2 \ -movflags faststart \ -vf scalemin(1280,iw):-2 \ output.mp4逐行解释一下这些参数因为每一个都是踩过坑之后才确定的。-profile:v high -level 4.1是H.264的档次和级别high profile兼容几乎所有现代设备level 4.1对应1080p级别的视频不会让老设备解码压力太大。-preset medium是编码速度和压缩率的平衡点个人项目没有时间压力的话也可以用slower来获得更小的文件体积但等待时间会明显变长。-crf 23是恒定质量参数数值越小画质越高、文件越大23是通用保守值对教学视频、演讲录像这类内容已经足够。-b:a 128k -ac 2把音频统一成双声道128kbps AAC这个参数后面还救过一次大坑会在第五节详细说。-movflags faststart特别重要它把MP4的元数据移动到文件头部这样播放器不需要下载完整个文件就能开始播放网络播放场景下体感差异巨大。最后的-vf scalemin(1280,iw):-2是把视频宽度限制到不超过1080p的一半左右高度保持等比且取偶数这是为了控制转码后的体积和码率——培训录像不需要4K1080p是甜点分辨率。有一个容易踩的点是-2这个高度取偶数的写法。很多编码器要求宽高必须是偶数如果源视频是奇数分辨率不处理的话转码会报错。FFmpeg里直接用-2就能自动计算最近的偶数省去手动判断的麻烦。2.2 HLS切片6秒一片兼顾启播速度和拖动流畅MP4转出来之后接下去就是切片。LunaTV没有用传统的“浏览器直接下载MP4播放”方案而是走了HLSHTTP Live Streaming协议。原因很简单HLS天生就是为网络流媒体设计的它把视频切成一个个小文件播放器逐个加载支持自适应码率拖动进度条时只需要下载对应片段体验比直接播一个大文件好太多。切片命令是这样的ffmpeg -i output.mp4 \ -codec copy \ -start_number 0 \ -hls_time 6 \ -hls_list_size 0 \ -f hls \ index.m3u8注意这里用了-codec copy意思是不重新编码视频直接拿刚才转出来的MP4进行切片速度快、画质无损耗。-hls_time 6表示每个切片约6秒这个数值是我反复对比过的。切片太短会导致请求数量暴涨增加服务器压力切片太长则拖动进度条时要多等几秒。6秒在个人服务器的带宽条件下是比较均衡的选择。-hls_list_size 0表示在索引文件index.m3u8里保留所有切片记录不删除旧切片。如果想让平台支持直播或滚动回看-hls_list_size设成一个有限值才合理但LunaTV是点播场景所以设为0。切片完成后目录里会多出一个index.m3u8文件和几十个.ts文件。.m3u8是播放索引.ts是实际的视频数据块。播放器拿到.m3u8地址先读取索引再按顺序去拉.ts文件。一开始我以为做到这一步就完事了但实际上还有码率自适应的问题。同一个视频如果只出一种码率的切片那么用户网络不好的时候就会一直卡顿。进阶做法是用 FFmpeg 输出多份不同分辨率和码率的切片再用master.m3u8索引把它们组合起来播放器会根据实时网速自动切换。当时LunaTV的重点场景是内网使用带宽比较稳定所以我暂时没有做多码率只在公网部署后加了720p和480p两档。实现方式其实就是在转码阶段同时跑两条FFmpeg命令再把两个子索引路径写进同一个master索引并不复杂。2.3 转码任务队列不让服务器被并发任务拖垮入库的视频一多转码就不是一次性同步操作能搞定的了。几百个视频文件如果同时丢给 FFmpeg 去跑CPU马上被打满服务器连响应网页都变得很慢。所以LunaTV在转码调度上引入了一个任务队列逻辑很简单每有新的视频文件上传就生成一个转码任务放进Redis队列后台Worker逐个取出任务执行FFmpeg命令。任务状态我用的是这样一条状态链pending - processing - done | - failed - retry(最多3次) - done每个任务对象里记录了源文件路径、输出路径、转码参数、当前重试次数。如果FFmpeg命令的退出码不是0说明转码失败任务会重新回到队列尾并且只有在失败次数小于3次时才会重试。这样做的原因是转码失败的偶发性很高——某些视频的封装格式比较奇怪第一次转码会异常退出但换成较低的错误容忍度参数后第二次就能成功。一句忠告如果需要转码的视频很多不要让同一台服务器既跑Nginx又跑密集转码。我一开始就是一台机器全包结果转码高峰期网页打开都卡。后来把转码Worker单独拆到另一台闲置的迷你主机上前端服务几乎不再受影响。如果只有一台机器至少要做到给转码进程设置CPU或系统负载限制别让它吃掉全部资源。我后来用cgroup限制了FFmpeg的CPU份额简单有效具体做法是给Worker进程单独建一个cgroup再设置cpu.max配额这样即使同时转码Nginx也有资源可以响应请求。3. 元数据与内容管理视频库可以被翻找的关键3.1 数据库设计一张主表加两张关联表转码切片只解决了“视频能播”的问题但用户打开平台之后看到的应该是按分类整理好的视频列表带封面、带标题、带时长而不是一堆文件名。这就需要一个结构化的元数据层。LunaTV的数据库结构非常简单三张表就够用CREATE TABLE videos ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, description TEXT, category_id INTEGER, duration INTEGER, poster_url TEXT, source_path TEXT, encoded_path TEXT, hls_path TEXT, status TEXT DEFAULT ready, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE categories ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT UNIQUE NOT NULL ); CREATE TABLE tags ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT UNIQUE NOT NULL ); CREATE TABLE video_tags ( video_id INTEGER NOT NULL, tag_id INTEGER NOT NULL, PRIMARY KEY (video_id, tag_id) );这里有几个字段值得说一下。duration在入库时通过FFmpeg的ffprobe命令自动获取不需要人工填写。poster_url指向自动抽取的封面图。hls_path指向index.m3u8文件的位置播放器拿这个字段去拼完整的请求地址。status字段看起来简单其实很管用——有些视频转码导出了问题或者切片不完整就标记成error前端列表里不会展示避免用户点开一个黑屏。分类和标签分别用单独的表管理是因为这两者的查询模式不同分类是树形结构且通常一个视频只属于一个分类标签则是多对多视频可以同时打“后端开发”和“数据库设计”两个标签搜索时可以按任意标签过滤。对于只有几百条视频的规模这样的设计完全够用不需要引入复杂的全文搜索引擎。等视频量级破万再考虑搞Elasticsearch个人项目到那个规模的概率其实很低。3.2 封面抽帧让列表页不再全是灰块每个视频都要有封面这是体验的基本盘。起初我打算手动上传封面图但给几百个视频逐个做图的效率实在低而且很多视频的封面本来就是一张视频里的画面截图没必要额外人工介入。后来写了一段自动抽帧的逻辑转码完成后自动从视频中间位置抽一帧作为封面。ffmpeg -i input.mp4 -ss 00:01:00 -vframes 1 poster.jpg-ss 00:01:00表示跳到视频的第60秒处之所以选这个时间点而不是第0秒是因为很多视频开头是黑场或者片头动画不是好的封面素材。跳到正片开始后的一分钟通常能截到比较有代表性的画面。如果你处理的视频都是片头很长的可以把这个参数调成百分比计算先用ffprobe读出总时长再乘以一个系数定位到中前段。抽出来的图再统一压缩成640x360的封面尺寸加载起来很快也不会因为图片太大拖慢列表页面。封面图生成之后poster_url字段就有了值前端列表页用img标签直接加载。这一步虽然简单但对平台整体的观感提升是决定性的。一个全是灰块的视频列表和一个每个视频都有精致封面的列表用户信任感可差太多了。3.3 API设计播放器只认一个统一接口前端页面和后端数据交互靠的是几个REST接口。LunaTV的API设计得很克制核心就四个GET /api/videos # 分页获取视频列表支持按分类、标签、关键词过滤 GET /api/videos/:id # 获取单个视频详情 GET /api/videos/:id/play # 获取播放地址m3u8文件的完整URL POST /api/videos # 上传新视频管理员用其中GET /api/videos/:id/play这个接口有个小设计它不会直接把hls_path返回给前端而是返回一个有时效性的临时播放地址。这么做是为了避免播放地址泄露后被别人永久盗链。实现方式是在hls_path后面拼一个基于时间戳和密钥生成的签名Nginx层校验签名过期则拒绝请求。对内部项目来说这个机制可能有点过剩但如果你打算把LunaTV部署到公网这个签名机制还是值得加的不然别人拿你的播放地址可以无限消耗流量。前端Vue页面在列表页请求第一个接口拿到视频元数据点击视频后跳转到详情页详情页通过play接口拿到播放地址然后交给播放器组件加载。整个链路非常直白没有复杂的嵌套调用出问题也容易排查。4. 播放器集成与多端兼容处理4.1 为什么用hls.js而不是直接上video.js播放器选型是LunaTV里比较关键的一个环节。市面上现成的播放器方案很多video.js、Plyr、DPlayer都有人用但深入了解之后会发现它们做的事情其实都是同一件事在原生video标签上面包一层UI和控制逻辑。真正麻烦的底层播放协议解析能不能播HLS取决于浏览器是否原生支持。HLS在Safari和iOS上是有原生支持的因为HLS本来就是苹果提出的协议。但在Chrome、Firefox这些桌面浏览器上浏览器不会原生去解析.m3u8需要借助MSEMedia Source Extensions接口自行喂数据。hls.js就是做这件事的库——它在支持MSE的浏览器里把.m3u8和.ts文件拉下来转成浏览器能消费的视频流喂给video标签。LunaTV的选型逻辑很简单桌面端用 hls.js移动端iOS/Android直接用原生HLS能力。有人会问为什么不直接在桌面上也唤起原生播放器因为桌面浏览器原生不支持HLS唤起原生播放器等于放弃桌面端。也有人在项目里用video.js但video.js的HLS插件底层走的也是hls.js那不如直接用hls.js少一层封装自定义起来更方便。集成方式非常直白import Hls from hls.js; const video document.getElementById(video); if (Hls.isSupported()) { const hls new Hls(); hls.loadSource(/api/videos/123/play); hls.attachMedia(video); hls.on(Hls.Events.MANIFEST_PARSED, () { video.play(); }); } else if (video.canPlayType(application/vnd.apple.mpegurl)) { // iOS Safari 原生支持 video.src /api/videos/123/play; }这里的Hls.isSupported()判断很关键它检查当前浏览器是否支持MSE。如果不支持就回退到原生HLS判断逻辑。有些很旧的浏览器两个分支都不走就要提示用户换浏览器了。4.2 各终端兼容性实测表格比文字更直白LunaTV部署完成后我在手头能接触到的设备上做了一轮兼容性实测结果如下终端类型操作系统/浏览器播放表现需要处理的点桌面ChromeWindows 11 / Chrome 120流畅秒开无桌面FirefoxWindows 10 / Firefox 120流畅无明显差异桌面SafarimacOS / Safari 17流畅走原生HLS分支手机iPhone XR / Safari流畅原生HLS手机Android 13 / Chrome基本流畅静默降码率平板iPadOS / Safari流畅无老笔记本Windows 7 / 老Edge卡顿需要转低码率版本Android端在信号弱时偶尔会出现缓冲后来在hls.js配置里加了一个lowLatencyMode和maxBufferLength参数的调整情况改善不少。重点提一下maxBufferLength默认值是30秒表示播放器会尽量缓存未来30秒的视频数据。在公网带宽紧张的时候这个值可以调小到10秒左右让播放器不会疯狂下载把带宽占满可以把流量留给其他用户。老Edge基于EdgeHTML内核对MSE的支持不完整hls.js偶尔会报错我的处理是不强求兼容直接提示用户升级浏览器。一个内部平台不值得为老浏览器付出额外开发成本这是务实的判断。4.3 锦上添花的功能倍速、记忆进度、自动下一集播放器基础能力通了之后我开始加一些体验向的功能。倍速播放几乎是刚需特别是培训类视频大部分人看回放都想1.5倍甚至2倍速。原生video标签有playbackRate属性直接设置video.playbackRate 1.5就行不需要额外的播放器插件。但要注意倍速播放下hls.js的解码压力会变大如果你发现视频期间偶发卡顿可以先排除一下是不是倍速导致的。实际测试中2倍速在普通笔记本上没什么压力4倍速会有明显丢帧但几乎没人用4倍速所以可以不管。记忆进度用的是localStorage每次播放时把videoId - 当前播放秒数存起来下次打开同一视频时自动seek到这个位置。这个功能实现成本五分钟但体验提升很明显尤其对于超过半小时的长视频。自动连播则是参考电视应用的逻辑一部课程有多集时当前播放结束后自动加载下一集。在Vue组件里监听ended事件然后切换播放源即可。这些功能说不上多高级但加起来让人觉得这个平台“像个正经产品了”而不只是一个播放器Demo。5. 部署上线后的性能调优与踩坑实录5.1 Nginx缓存配置避免重复打穿回源接口LunaTV的播放请求链路是前端拿到播放地址 - 请求.m3u8索引 - 逐个请求.ts切片。每个切片都是一个完整的HTTP请求如果用户多看几个视频后端接口就会被频繁打到。好在Nginx天生适合处理这种静态文件密集访问的场景。我的Nginx配置里有这么一段location /media/ { alias /data/lunatv/media/; expires 1d; add_header Cache-Control public, no-transform; access_log off; }expires 1d表示.ts切片和封面图在客户端缓存一天同一用户重复观看时大部分请求直接命中本地磁盘缓存不再走网络。no-transform防止某些中间代理擅自压缩或转码视频文件导致播放异常这个是我在一台CDN节点后面踩过坑之后才加上的。LunaTV内部还有一个更简单的缓存策略把转码完成的视频按天目录隔离开比如/data/lunatv/media/2024/01/15/{videoId}/index.m3u8。这样做的好处是如果某天上传的视频出了问题可以直接删掉对应日期目录不用一个个文件排查。时间戳目录还能让Nginx的缓存清理逻辑变得非常直观。5.2 公网带宽的瓶颈码率低一点播放流畅一倍项目一开始跑在局域网里完全感觉不到带宽压力。后来有人问能不能放到公网访问我就临时在一台固定带宽10Mbps的云服务器上做了个测试。结果很尴尬本地测速20Mbps的网络访问公网上的视频还是卡。一查才发现10Mbps的上传带宽根本扛不住多人同时看1080p视频。一个1080p 30fps的视频码率大概是4到5Mbps理论上只能同时供2个人流畅观看第三个用户一进来就开始缓冲。这个问题没有银弹唯一的办法是压缩码率或降低分辨率。后来我在转码脚本里同时输出两档一档720p适合公网一档480p适合弱网环境。播放器通过master索引自动选择合适档位每秒上报带宽情况hls.js会自动切换码率。如果你要部署到公网最应该关注的不是服务器CPU而是出网带宽。云服务器一般都有带宽限制本地服务器则有上行限制。把码率压到2Mbps左右10Mbps带宽能同时服务4到5个用户体感会好很多。5.3 无声视频排查流水线AAC 5.1声道惹的祸上线不到两周就有一个用户反馈“视频有画面但没有声音。”我第一反应是浏览器的自动播放策略问题有些浏览器会拦截带声音的视频自动播放。但手动点击播放按钮之后还是没有声音这就排除了自动播放限制。排查步骤是这样的。先拿一个有问题的视频文件用ffprobe看它的音频信息ffprobe -show_streams input.mp4输出的音频流信息里有一行channels6这意味着音频编码是5.1声道。问题就在这浏览器原生播放HLS时对5.1声道的解码支持是分设备、分浏览器的。部分安卓设备遇到5.1声道的AAC流直接不输出声音表现为画面正常播放但无音频。解决方式也简单转码时强制把多声道混音为双声道立体声。回头看我在转码脚本里其实写了-ac 2这个参数但那是给标准入库流程用的。这批出问题的文件是早期用另一个老脚本转的那个脚本没有这个参数。发现问题后我把所有老视频重新跑了一遍转码全部加上-ac 2参数之后再没出现过无声反馈。这个案例提醒我统一的转码入口非常重要。不要今天手跑一条命令明天改个参数再跑一条时间一久根本不知道哪批文件是按照什么标准转出来的。最好是有一个配置文件统一管理转码参数任何修改都走同一个入口。5.4 Safari下时间戳错乱tsoffset参数救场另一个比较隐蔽的问题是部分视频在Safari里会表现为画面卡住不动、但时间进度还在走。这个现象在Chrome里完全正常只在Safari上出现。排查过程比较折腾最后指向了输入文件中携带的PTS时间戳问题。部分视频文件的PTS显示时间戳不是从0开始的而是带了一个很大的初始偏移量。hls.js在Chrome里会自动修正这个偏移但Safari原生HLS播放器不会导致播放器在seek时计算出错误的目标时间点画面就卡住了。FFmpeg转码时加一个参数可以强制重置时间戳ffmpeg -fflags genpts -avoid_negative_ts make_zero -i input.mp4 ...-fflags genpts强制重新生成PTS-avoid_negative_ts make_zero让时间戳不从负数开始。这两个参数加上之后Safari上的卡顿问题基本消除。经验就是当同一个视频在Chrome正常但Safari异常时先考虑时间戳和封装层面的差异而不是急着怀疑播放器的问题。6. 后面怎么演LunaTV的下一步规划与几点实在建议6.1 弹幕、字幕和移动端封装LunaTV目前是一个能用的内部平台但离我理想中的形态还有几个明显的短板。弹幕功能是我一直想加的因为内部培训视频的氛围跟B站公开课很像弹幕里常有人补充关键知识点或者提醒“这里很重要”。技术上弹幕的核心是一个基于WebSocket的消息通道视频以时间轴为坐标客户端在特定时间点把弹幕叠加绘制在画面上。实现起来不算太难主要是弹幕消息的审核和管理要做不然弹幕区会变成吵架现场。字幕方面计划在转码完成后自动调用本地的Whisper模型做语音识别生成.vtt字幕文件。这个方案的好处是私有化部署不会把视频内容上传到第三方服务对内部项目尤其重要。Whisper在普通家用机器上转1小时音频大概需要10分钟左右在后台等待转码时一并处理用户无感知。移动端目前响应式布局用着还行但我还是想封装成一个简单的手机APP。这不只是为了一个图标而已而是因为移动端浏览器在切到后台再回来时视频播放经常被系统暂停原生封装可以在生命周期管理上做更精细的控制。如果只是内部用直接上PWA方案也能解决大部分问题不需要走应用商店审核。6.2 给想自己搭视频平台的同行几句实在话做LunaTV这几个月有几个体会很深趁着收尾一并说了。第一不要上来就规划大架构。视频平台听起来复杂但真正核心的链路就是“转码-切片-元数据-播放器”这四步。先把一条视频从上传到播放的所有环节走通再去想什么自动转码队列、多码率自适应、CDN分发这些东西都是有了真实用户之后才需要加的。第二转码参数和脚本一定要做版本管理。我吃过没有版本管理的亏同一个视频用不同时间点的脚本转出来效果完全不一样出了问题都不知道该找哪个参数。现在LunaTV的转码参数全部放在一个配置文件里任何修改都在Git里留历史记录回滚一键完成。第三播放器相关的bug排查看起来很玄学但绝大多数都能追溯到编码、时间戳或声道这三个方向。Chrome正常Safari卡查时间戳。有画面没声音查声道数。手机弱网卡顿查码率。只要排查方向对不愁找不到根因。LunaTV这个项目没有用到什么前沿技术但它让我把流媒体这条链路彻底摸透了。后续如果真把弹幕和Whisper字幕加上这个平台应该还能在团队里用很多年。如果你也在攒一个属于自己的视频库别犹豫从第一个FFmpeg命令开始跑吧。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →