从零搭建个人媒体中心:HLS视频转码与多端播放实践
做LunaTV这个项目,最初是我被自己手里那堆乱七八糟的视频设备逼出来的。手机上存了一半的课程,电脑硬盘里塞着剪完没归档的素材,客厅电视的播放器只能读U盘,到了真想在客厅大屏上看点东西的时候,得先在书房把文件拷来拷去,折腾十分钟才能按下播放键。后来团队又需要一个轻量的直播和点播演示环境,我就干脆把这事当成一个正经项目来搞,代号就叫LunaTV,目标很朴素:一套内容源,统一转码,统一分发,手机、电脑、电视都能随手打开就播,不需要反复拷贝文件,也不需要装一堆客户端。
这篇文章就把LunaTV从想法到落地的完整过程捋一遍,包括架构怎么拆、技术选型为什么这么定、核心链路怎么做,以及实际运行里踩过的那些坑。适合想搭个人媒体中心、小型视频平台,或者正在做视频类产品原型的朋友参考,就算你暂时不打算自己动手写代码,看完也能对视频点播、直播这类系统的内部结构有个清晰认知。
1. 项目定位与整体设计思路
1.1 最初想解决的三个痛点
做之前我列了一下最难受的点,其实就三个。
第一是设备割裂。手机、平板、电脑、电视,各自有各自舒服的播放方式,但内容散落在不同设备上,没有一个统一入口。第二是格式兼容。同一个视频文件,在手机里能放,到了电视上可能就提示“不支持的格式”,或者字幕乱码。MP4封装、H.264编码这种组合还算通吃,但遇到HEVC、MKV封装、外挂字幕,情况就不可控了。第三是播放体验不稳定。电视端很多自带播放器对网络流媒体的支持很差,拖动进度条要等半天,视频甚至直接黑屏。
LunaTV要解决的,不是做一个多复杂的“视频网站”,而是把这些碎片问题收敛到一个系统里:服务端负责把各种格式统一转成浏览器和播放器都好处理的HLS流,前端只需要一个页面就能接入,不同设备用同一套代码,播放体验保持一致。
1.2 功能边界与目标用户
我在立项的时候刻意控制了功能范围。第一版主要做四件事:本地视频和直播流的上传接入、服务端自动转码切片、统一的后端接口、以及一个支持点播和直播切换的Web播放器。后台管理界面也做,但只做最基础的文件列表和状态展示,不做复杂的权限系统、用户体系,也不做推荐算法。
这个边界决定了LunaTV适合的使用场景很清晰:个人或小团队搭建家庭媒体中心、内部培训视频库、活动演示用的点播页面、甚至小型网络直播中转。它不适合用来做商用的大规模视频平台,那需要的内容分发网络、多码率自适应调度、版权保护系统,不是一个小项目能解决的。
1.3 整体架构怎么分层
LunaTV的逻辑架构我分成了五层,后面所有开发工作都是围绕这五层展开的。
接入层负责接收视频文件和直播流。文件上传支持拖拽和命令行工具,直播流接入走RTMP或者通用的拉流地址。处理层是核心,包含ffprobe探测元数据、ffmpeg转码、HLS切片、缩略图生成。这一层把各种乱七八糟的输入统一成一个标准输出。存储层管理原始文件和转码产物,我用的就是普通磁盘目录加一份数据库索引,没有引入对象存储。分发层用Nginx提供HLS切片和静态文件的访问能力,加上简单的防盗链校验。播放层是浏览器前端,负责拉取播放列表、实例化播放器、处理用户交互。
这五层看起来简单,但每一层都有值得抠的细节。比如接入层的文件格式判断,如果文件名是.MKV但实际内容是AVI,按后缀判断就会出事。处理层的转码参数如果定得不好,转出来要么画质差,要么体积大。这些坑后面会详细讲。
2. 技术选型与关键原理解析
2.1 播放器:为什么不直接引一整个全家桶
播放器我第一个想到的是用现成的开源播放器。市面选择很多,像Video.js、Plyr、DPlayer,都是基础上能直接用的。但实际接进去发现,它们都是“外壳”,真正的解码能力还是依赖于浏览器原生能力或者底层库,而且功能越全的播放器,打包体积越大,对电视端的老浏览器反而不友好。
所以LunaTV的思路是:播放器外壳尽量薄,核心交给hls.js和dash.js这些专项库去处理。hls.js负责在支持Media Source Extensions的浏览器里把HLS流拉下来喂给video元素,dash.js负责DASH协议。不支持MSE的场景,回退到浏览器原生HLS能力。Safari在macOS和iOS上原生支持HLS,所以这一套下来覆盖面已经足够广。
选型之后我自己封装了一个很小的播放器壳子,只包含视频元素、控制栏、清晰度切换、倍速、进度记忆这几个必要功能。少依赖,后期出问题的时候好排查,也方便针对电视端做遥控器按键适配。
2.2 流媒体协议:HLS为主,FLV做低延迟补充
协议选型是LunaTV里比较关键的一个决定。点播场景我直接选HLS,原因三个:浏览器兼容性最好,无论桌面端还是移动端不用装插件;支持标准的分片拉取,天然适合HTTP缓存;切片文件是独立的TS或MP4片段,某个分片出错只需要重试那一片,不会整个播放失败。
直播场景稍微纠结了一下。HLS直播延迟通常在5到15秒,对互动要求高的场景不够用。所以我额外加了HTTP-FLV的直播播放通道,直播延迟能压到1到3秒左右。代价是用HTTP-FLV需要额外的播放器支持,我直播场景用的是mpegts.js这个库,效果还不错。
简单说,LunaTV的点播清一色HLS,直播就根据场景分两套:普通演示用HLS就行,需要互动的活动用FLV通道。这样比较务实,不会为了“技术先进”强行上WebRTC那一套,避免带来一系列服务器端和客户端的复杂度。
2.3 服务端技术栈:轻量就是王道
服务端我选了Node.js,原因是前端的播放器要用JavaScript写,前后端同一种语言,维护起来省心。框架用的是Express,没上NestJS那些重型框架,毕竟LunaTV的接口数量很少,核心就那七八个。
数据库用的是SQLite。很多人可能觉得SQLite不够“正经”,但对LunaTV这个体量,完全够用。视频元数据、专辑分类、播放进度,这些数据量撑死几千条记录,SQLite单文件存储,备份就是拷一个文件,省去MySQL的部署和维护成本。如果是团队协作、并发写入特别高的场景,我会换PostgreSQL,但LunaTV不需要。
目录结构上,我坚持把“逻辑处理”和“具体命令调用”分开。比如转码任务,后端的接口只负责创建任务、记录状态、返回结果,真正的ffmpeg调用交给一个独立的工作进程去执行。这样转码卡住或者崩溃的时候,不会把整个API服务拖死。
2.4 转码参数:宁可用眼睛看,不要只看参数表
ffmpeg的转码参数,网上教程一搜一大把,但很多参数直接拿来用会踩坑。LunaTV里我最终定下来一套默认参数,点播转码用H.264编码、CRF值23、预设用medium,音频用AAC、码率128k或192k,然后封装成HLS切片。
选H.264而不是H.265,是因为H.265在各端兼容性还不够统一,尤其是老一点的电视和浏览器,硬解支持很混乱。H.264虽然压缩率低一些,但兼容性最稳妥。CRF值23是很多场景下画质和体积的平衡点,追求更高画质可以降到18,但体积会明显增大。预设选medium是因为太高的预设(比如slow、veryslow)压缩时间长,对实时转码场景不友好;太低的预设文件体积又太大。
有个重要经验是,参数表可以给参考,但最终还是得拿实际视频看效果。动态画面多的视频和高清录屏是不同的内容类型,按同一组参数跑出来的结果可能一个流畅、一个满屏噪点。所以LunaTV的模板允许按目录覆盖参数,比如“录屏目录”单独用CRF 28,“电影目录”用CRF 20,这样既有默认值兜底,又能灵活调整。
3. 从零开始搭核心链路
3.1 动手前的环境准备
基础环境我用了三样:一台Ubuntu 22.04的服务器(或者家里的老PC),装好Node.js 18以上、ffmpeg 4.4以上、Nginx和SQLite3。如果你只是在本地试验,用Windows也没问题,核心流程是一样的,就是路径写法要注意。
我建议在正式写代码前,先确认ffmpeg是否带了你需要的组件。跑一下
ffmpeg -version
,重点看有没有
libx264
。很多默认安装的ffmpeg是不带x264编码器的,会导致后面转码直接失败。如果没有,Ubuntu可以用
apt install ffmpeg
,或者从官方静态编译包解压,基本都带全了。
目录结构我习惯这样规划:
lunatv/
api/ # Node.js后端
server.js
routes/
db.js
worker/ # 转码工作进程
transcode.js
storage/
origin/ # 原始文件
processed/ # 转码产物
thumbs/ # 缩略图
public/
player.html
hls/ # Nginx对外服务的根目录
nginx/
lunatv.conf
这个结构乍看没什么特别,但能有效防止后面混乱。我把原始文件和转码产物分开存,转码失败也不会污染正在对外服务的目录。
3.2 视频入库:先用ffprobe探底
视频文件上传到服务器之后,不能直接就开始转码。第一步永远是探测元数据。用ffprobe拿到视频的真实格式、编码器、分辨率、时长、帧率、音轨信息。这一步能避免很多低级错误。
我封装了一个探针函数,大概思路是执行:
ffprobe -v quiet -print_format json -show_format -show_streams input.mkv
然后解析JSON,拿到需要的字段。这里有个容易忽略的点:有些文件看起来是MP4,但里面的视频流是MPEG-4 Part 2而不是H.264,如果不探测直接走转码流程,参数匹配会出错。还有的MKV里有多条音轨,其中一条是评论音轨,你转码时得决定保留哪个。
探测完之后,把元数据写入数据库,状态标记为“待转码”。同时生成一个任务ID,后续所有环节都用这个ID关联,而不是用原始文件名。因为原始文件名经常有中文、空格、特殊字符,直接拿来当文件名用,后面URL编码和文件定位会很难受。
3.3 转码与切片:一行命令背后的门道
探测通过后,工作进程就开始处理转码。核心命令长这样,我做了一点简化:
ffmpeg -i input.mkv \
-c:v libx264 -preset medium -crf 23 \
-c:a aac -b:a 128k \
-vf "scale=-2:720" \
-hls_time 6 -hls_playlist_type vod \
-hls_segment_filename "output_%04d.ts" \
output.m3u8
解释一下几个关键参数。
-hls_time 6
是每个切片6秒,太短会导致请求数暴涨,太长会让拖动进度条时的定位粒度变粗。
-hls_playlist_type vod
表示这是一个完整的点播列表,播放器会认为所有切片都已经存在,可以做全量预加载;如果是直播,就不能用这个参数。
-vf "scale=-2:720"
的作用是统一输出高度为720p,宽度按原始比例自动计算。用-2而不是-1,是ffmpeg的一个细节要求:某些编码器希望宽高是偶数,-2会自动帮我们处理成偶数,避免“width not divisible by 2”这种报错。这个报错我第一周至少遇到了七八次。
转码过程是耗时的,所以我特意做了个简单的并发队列,默认同时跑两个转码任务。之前贪快把并发调到4,结果CPU被打满,Nginx响应变慢,连播放都跟着卡。后来老实调回2,整个系统稳定多了。机器配置高的话可以调,但一定要留出播放服务的余量。
转码结束后还需要生成一个缩略图,方便后台管理和播放器展示预览。命令是:
ffmpeg -i input.mkv -ss 00:05:00 -vframes 1 -vf "scale=-2:360" thumb.jpg
这里
-ss
放在
-i
前面和后面效果差异很大。放在前面,ffmpeg会先快速跳转再解码,速度快,但定位可能不是非常精确(某些关键帧模式下);放在后面,是先解码到指定时间点再截帧,精确但慢。我做缩略图追求速度,所以把它放在前面,用关键帧附近的一帧就行。
3.4 播放接口:把切片地址安全地交出去
转码完成之后,Nginx要能对外提供切片的访问。我直接让Nginx静态服务转码产物目录,同时加了一层简单的防盗链校验。
Nginx的关键配置如下:
location /hls/ {
alias /home/lunatv/storage/processed/;
types {
application/vnd.apple.mpegurl m3u8;
video/mp2t ts;
}
add_header Cache-Control "no-cache";
valid_referers none blocked server_names lunatv.example.com;
if ($invalid_referer) {
return 403;
}
}
这里有两个细节。第一是
types
里的MIME类型必须写对,
m3u8
如果被识别成纯文本,某些播放器就会直接下载而不是播放。第二是防盗链用
valid_referers
虽然不算绝对安全,但能挡掉大部分“顺手盗链”的情况。真正的安全要靠在URL里加签名和时间戳,那种做法更复杂,LunaTV当前阶段还没到那个程度。
后端接口我给了两个关键的大接口。一个是
GET /api/videos
,返回视频列表,包含ID、标题、缩略图地址、清晰度标签、转码状态。另一个是
GET /api/videos/:id/playlist
,返回这个视频对应的m3u8地址和字幕文件地址。播放器拿到这些信息后,直接拼出完整的视频播放地址。
这里有一个设计教训:一开始我让后端直接把m3u8文件内容读出来返回给前端,后来发现切片的相对路径一旦Nginx根目录调整就全部失效。后来改成只返回一个播放地址,让播放器自己去拉m3u8,这样后端和静态服务的解耦就干净了。
3.5 前端播放器:hls.js接进来其实不难
前端部分我做的相对简单。页面里只有一个video标签加一个控制栏。播放的初始化逻辑分成几段,核心思想是:支持MSE的浏览器用hls.js,Safari等原生支持HLS的直接把src赋值给video标签。
用hls.js加载视频的代码大致是:
if (Hls.isSupported()) {
const hls = new Hls({
maxBufferLength: 30,
maxMaxBufferLength: 60,
enableWorker: true,
});
hls.loadSource(playlistUrl);
hls.attachMedia(video);
hls.on(Hls.Events.MANIFEST_PARSED, () => {
video.play();
});
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
video.src = playlistUrl;
video.addEventListener('loadedmetadata', () => {
video.play();
});
}
maxBufferLength
是允许播放器缓冲多长的数据,我设成30秒。这个值太小容易在弱网下卡顿,太大会占大量内存,电视端尤其明显。
enableWorker
让hls.js把解析分片的工作放线程里跑,界面流畅度会好一些。
播放控制部分,我额外做了一件事:把播放进度每15秒上报一次到后端。这样用户中途退出,下次打开还是能回到上次看的位置。这个功能听着小,但在实际使用里非常提升体验。
3.6 多端部署:电视和手机都能看
部署到服务器之后,我紧接着处理了多端访问的问题。电脑和手机直接开浏览器输入地址就行。电视端我试了几种方式,最后发现大多数智能电视自带浏览器虽然性能一般,但播放H.264编码的HLS流问题不大。如果没有浏览器,可以装一个支持网页播放的第三方播放器应用,指向同一个地址。
这里要注意的是网络环境。如果只是在家庭局域网内使用,后端API和Nginx都监听内网IP就够。如果想在外网访问,需要做端口映射或者通过支持HTTPS的反向代理暴露服务,但外网访问的时候一定把鉴权加上,否则谁拿到地址都能看你的视频库。LunaTV目前主要的运行环境是内网,外网访问只作为临时演示用。
4. 实际运行中的问题与排查方法
4.1 播放首帧慢,问题出在切片大小
上线第一天就有同事反馈:手机打开一个视频,转圈要好几秒才出画面。我用浏览器DevTools看了请求瀑布图,发现m3u8文件很快就返回了,但第一个TS切片加载花了快3秒。再看切片文件大小,每个居然有3MB多。
原因是我把视频码率压得比较低,但切片时长是6秒,一个6秒的720p视频按2Mbps算,差不多1.5MB,可那个视频本身动态画面多,瞬时码率飙到4Mbps以上,切片就膨胀到3MB。解决办法是,将切片时长调整为4秒,同时加一个
max_muxing_queue_size
参数避免封装队列溢出。调整之后,首帧时间从3秒降到1秒左右。
这个案例给我的教训是:切片的时长不是固定写死就完事的,它得结合内容类型和码率分布来调整。
4.2 音画不同步的排查经历
有一次转出来的视频,播放到十几分钟后音画差了两三秒。我开始以为是播放器缓冲策略的问题,换了播放器还是这样,又怀疑是HLS切片切到了GOP的中间,导致画面跳帧。
后来用ffprobe逐帧分析原始文件才发现,原始视频本身就存在音画不同步的问题,大概从第10分钟开始逐渐累积。这种源文件的问题,转码只是把它原样保留了下来。解决办法是在转码命令里加一个音频延时参数:
ffmpeg -i input.mkv -itsoffset -2.5 -i input.mkv ...
但这不是通用解法,因为每个文件的偏移量不同。后来我在LunaTV的后台加了一个“音频延迟调整”的选项,遇到这种文件,手动填一个偏移值再重新转码,比写一套全自动检测方案省事得多。
4.3 老电视播放不了的问题
电视端的问题五花八门。最常见的是老电视的浏览器不支持MSE,导致hls.js根本起不来。其次是支持的视频编码很有限,一旦视频编码是Main而不是High,部分老设备就黑屏。
最终我定了一个兼容策略:转码时增加一条低清晰度附加流,分辨率为360p,编码用H.264 Baseline级别。Baseline在几乎所有设备上都能硬解,虽然画质一般,但作为“兜底”非常管用。前端播放逻辑里检测到hls.js初始化失败,就自动切到这条低清晰度流。
4.4 磁盘空间被日志和切片堆满
运行了几个月后,发现磁盘占用越来越大。排查一看,前几名分别是:旧的转码临时文件、Nginx访问日志、还有历史直播产生的没有被清理的TS切片。问题是我只写了转码成功后的清理逻辑,没写定期的过期清理任务。
后来我写了一个简单的定时任务:每天凌晨删除三天前的临时目录,Nginx日志用logrotate按天切割保留7天,直播录像默认只保留最近20场。加完之后磁盘占用至少降了一半。再后来,我又给后台加了个可视化磁盘占用页面,这事及时暴露比事后排查舒服得多。
4.5 并发访问一高就卡
某次活动直播演示,同时有二十多个人在线看,结果没几分钟直播就一卡一卡的。我查了半天,发现瓶颈不是带宽,而是那个老旧的机械硬盘扛不住大量TS切片同时读取。
LunaTV当时把直播切片也写在和点播同一个磁盘上,直播切片的持续写入加上点播切片的随机读取,磁盘IO直接饱和。我把直播切片临时目录换到了内存盘(tmpfs)上,同时让Nginx开启缓存,把热点的直播切片缓存到内存,问题立刻缓解。如果是纯SSD环境,这个现象会轻很多,但IO多的时候还是建议把直播路径和点播路径分开存放。
5. 一些体会和还能扩展的方向
LunaTV这个项目做到现在,我最深的一个体悟是:视频系统里,稳定比炫技重要得多。最初我试图把所有视频都转成4K,还加了复杂的多码率自适应,结果就是转码耗时长、存储暴涨,而实际观看时大多数人根本不会去切换清晰度。后来我把默认清晰度定为720p,只在需要的时候手动开启1080p,整体体验反而好了。
如果你也想做一个类似的系统,我的建议是先用小规模数据跑通全流程,不要一上来就追求大而全。先把“上传一个文件到能点开播放”这条链路走通,再逐步加直播、加多码率、加管理后台。这套系统后续可以扩展的方向其实不少,比如给视频自动生成字幕、根据内容类型自动选择转码模板、增加用户登录和观看权限控制,甚至可以把播放数据导出来做一个简单的统计看板。
技术永远有更好的方案,但对一个实际要天天用的系统来说,“够用、稳定、好修”才是最重要的。LunaTV目前还在我家的那台小服务器上跑着,每天晚上吃完饭用它投个视频到电视上看,基本已经忘了“找文件”这件事的存在。对一个项目来说,这应该算是挺不错的结局了。
更多推荐

所有评论(0)