背景视频播放优化实录:从mp4卡顿到HLS分片秒开
一、问题现象:三种设备三种病
博客首页有一段 245 秒的樱花飘落背景视频(firefly.mp4,14.94MB)。上线后发现同一个视频在不同入口表现完全不同,没有一种让人满意:
| 场景 | 现象 | 一句话根因 |
|---|---|---|
| 手机浏览器 / 微信 | 点播放变”下载界面”;微信里 dev 链接点播放完全放不了 | CF Pages 不支持 Range,浏览器把视频当文件下载 |
| 电脑 · dev(Cloudflare Pages) | 点播放十几秒没反应,视频放不出来 / 加载极慢 | 15MB 整包下载,加载路径长 |
| 电脑 · io(GitHub Pages) | 能播但明显卡顿,拖动进度条基本没法用 | GitHub 国内线路慢(实测约 22KB/s),边下边播跟不上播放速度 |
需求非常明确:完整 245 秒不截取、双平台都能播、加载快。压缩治标不治本,截取视频被直接否决,最终落在 HLS 分片方案上。
二、根因定位:Range 请求与平台能力差异
浏览器播放 mp4 时,“边下边播”依赖 HTTP 的 Range 请求:浏览器先请求文件开头一小段(Range: bytes=0-1048575),服务器返回 206 Partial Content 和这一段的字节,浏览器解码开头就能开始播放,播放的同时继续请求后续分段。这个机制不需要服务器支持,只需要它响应 Range 头。
对本站两个托管平台做了实测:
| 平台 | 带 Range 请求的结果 | 含义 |
|---|---|---|
| Cloudflare Pages(dev) | 返回 200 + 完整 15.66MB | 不支持 Range,只能整包下载完才能播 |
| GitHub Pages(io) | 返回 206 分段响应 | 支持 Range,但国内线路慢(实测渐进下载约 22KB/s) |
于是 mp4 单文件在两条路上都受限:
- dev 上:不支持 Range → 15MB 必须整体下载完 → 手机上就表现为”下载界面/放不了”,电脑上点播放后长时间无反应;
- io 上:支持 Range 但线路慢 → 下载跟不上播放速度 → 卡顿。
三、视频压缩的取舍:五个版本的迭代
在动播放方案之前,先尝试把视频压小。245 秒的视频,Cloudflare Pages 单文件上限 25MB,可用码率预算约等于 25MB × 8 / 245s ≈ 816kbps,动画画面(樱花飘落 + 人物)对这个码率非常敏感:
| 版本 | 参数 | 体积 / 码率 | 结果 |
|---|---|---|---|
| v1 | crf 35 | 13MB / 448kbps | 动态细节全糊,弃 |
| v2 | crf 27 | 30.3MB | 超 25MB 限制,弃 |
| v3 | 2-pass 900k | 29.3MB | ABR 码率有偏差,仍超限,弃 |
| v4 | crf 30 | 24.27MB(720p) | 压在限制内,本地备份,未上线 |
| 上线版 | crf 30 | 14.94MB(480p) | 清晰度可接受,但单文件播放问题依旧 |
压缩只能把体积从 30MB 压到 15MB,“服务器不支持 Range”这个硬伤不会因为文件变小而消失——问题不在文件大小,在下载方式。这条路走到头了,必须换播放架构。
四、HLS 分片:行业标准做法
HLS(HTTP Live Streaming) 是 Apple 提出的流媒体协议,也是当前事实上的行业标准:把视频切成若干 6 秒左右的小分片(.ts 文件)+ 一个播放清单(playlist.m3u8),播放器按需逐片拉取。YouTube、B 站、Netflix 的网页播放都是 HLS / DASH(DASH 是另一套类似标准)的思路——“分片传输”本身就是行业标准做法,而不是什么 hack。
为什么分片能绕开”不支持 Range”?因为每个分片是完整的小文件(约 355KB),服务器直接返回整个文件即可,根本不需要 Range;播放器一次只拉几片,播到哪拉到哪,总下载量随播放进度增长,而不是一次性拉完 15MB。
1. 分片怎么用:三步走,以及和 mp4 / DASH 的区别
HLS 分片不是黑魔法,用起来就三步:
- 切片:ffmpeg 把视频按 6 秒一段切成几十个
.ts小文件,同时生成一个playlist.m3u8清单(记录每片的地址、时长、顺序); - 上传:把
m3u8+ 所有.ts分片一起传到托管平台——每个分片都是独立小文件,普通静态托管就能直接提供; - 播放:播放器(hls.js)先拉清单,再按播放进度逐片拉取对应分片,播到哪拉到哪,不需要一次下载整部视频。
YouTube、B 站、Netflix、抖音这些主流平台的网页播放,底层都是这个思路(HLS 或 DASH 分片流)——所以”分片”是行业标准做法,不是 hack。
那 HLS 和”mp4 渐进式播放”、DASH 到底有什么区别?一张表说清:
| 对比项 | mp4 渐进式播放 | HLS 分片 | DASH 分片 |
|---|---|---|---|
| 文件形态 | 单个 mp4 文件 | 几十个 .ts 分片 + 1 个 m3u8 清单 | 多个 MP4 分片 + 1 个 MPD 清单 |
| 播放方式 | 浏览器靠 Range 请求边下边播 | 播放器读清单、按需逐片拉取 | 同 HLS,按清单逐段拉取 |
| 依赖服务器支持 Range? | 是(不支持就整包下载完才能播) | 否(每个分片是完整小文件,直接返回) | 否 |
| 浏览器原生支持 | 所有浏览器 | Safari 原生支持;Chrome/Firefox 靠 hls.js 补 | 需浏览器 MSE 能力,兼容性较散 |
| 谁在用 | 老站/小站常见 | YouTube、B 站、Netflix、Apple、抖音 | 部分平台自选(如部分安卓端) |
| 本项目 | 备用链路 | ✅ 主链路 | 未用 |
区别的核心就一行:mp4 渐进式靠”服务器配合分段返回”(Range),HLS 靠”文件本身就切成段”——所以 HLS 对不支持 Range 的托管(如 Cloudflare Pages)天然免疫。
2. 切片命令
用 ffmpeg 把 firefly.mp4 切成 41 片 + VOD 清单(-hls_playlist_type vod 表示点播、非直播):
ffmpeg -y -i firefly.mp4 \ -c:v libx264 -preset medium -crf 30 -pix_fmt yuv420p \ -c:a aac -b:a 96k \ -force_key_frames expr:gte(t,n_forced*6) \ -hls_time 6 -hls_playlist_type vod \ -hls_segment_filename seg_%04d.ts \ playlist.m3u8产物:playlist.m3u8 + seg_0000.ts ~ seg_0040.ts(41 片),总大小 14.56MB,与原始 mp4 相当(质量没有额外损失)。
3. 播放链路设计
播放器逻辑(BackgroundPlayer.astro):
function doPlay() { var url = getCurrentUrl(); // 单视频 + 有 HLS 配置 + 未启用备用源时优先走 HLS if (hlsUrl && !isMultiple && !fallbackTried) { playHls(hlsUrl, function () { playMp4(url); }); } else { playMp4(url); }}playHls():点击播放时才动态加载hls.min.js(约 605KB,懒加载不拖慢首屏),初始化 Hls 实例,MANIFEST_PARSED后video.play();- HLS 致命错误(
data.fatal)自动destroyHls()并回退 mp4; - mp4 再失败进备用源,全部失败才显示图片壁纸。
4. 两个值得记录的坑
坑一:is:inline 脚本里 import('hls.js') 是无效的。 播放器脚本是 Astro 的 is:inline(原样输出、不走打包),里面的裸模块名 hls.js 浏览器根本无法解析,会静默走 mp4 分支。修复:把 hls.min.js 放到 public/assets/js/,用站点根绝对路径动态导入:
import('/assets/js/hls.min.js') .then(function (mod) { var Hls = mod.default || window.Hls; if (!Hls) { onFail(); return; } startWith(Hls); }) .catch(function () { onFail(); });坑二:补丁把 doPlay 打成了嵌套空壳。 第一次集成时新旧 doPlay 嵌套,外层函数只声明内部函数、什么都不执行——点击播放毫无反应,但手动调用内部逻辑又正常,非常隐蔽。构建产物里 minifier 把内层函数重命名为 doPlay2 才暴露出来。修复:把 hlsInstance / destroyHls / playMp4 / playHls / doPlay 全部提升到同一作用域。
5. 双平台实测
| 站点 | 实测结果 |
|---|---|
| io(GitHub Pages) | readyState=4、854×480、播放中、跨域拉取 dev 分片正常 |
| dev(Cloudflare Pages) | readyState=4、854×480、播放中、buffered 达 54s |
点击播放后 1-2 秒出画面,手机/微信不再出现下载界面,电脑端不再卡顿。分片约 355KB/片,在 dev 上实测约 0.4 秒/片下载。
五、双域名分工与自定义域名的真相
- io(GitHub Pages):主站,用户访问和微信分享的域名;页面跨域拉取 dev 的分片;
- dev(Cloudflare Pages):视频仓库,存放 m3u8 + 41 个分片;国内下载快;
_headers配置Access-Control-Allow-Origin: *允许跨域。
关于自定义域名,一个常见误区是”绑了自定义域名视频就不卡了”。实际上自定义域名只是换门牌号:把 lycaonide.github.io 绑成 lycaonide.com,访问时底层还是同一台 GitHub/Cloudflare 服务器,是否支持 Range、国内线路快慢完全不变。把”域名无关”这件事说透——视频卡顿/下载界面的根因是两个平台能力问题,跟域名无关:
| 根因 | 出在哪 | 自定义域名能改吗 |
|---|---|---|
| CF Pages 不支持 Range(15MB 必须整包下载) | dev 的服务器 | ❌ 域名还是指到 CF 的服务器 |
| GitHub Pages 国内线路慢 | io 的服务器/线路 | ❌ 域名还是指到 GitHub 的服务器 |
真正能让 mp4 直接边下边播的条件是:同时满足”有自定义域名 + 视频托管到支持 Range 且国内快的平台”(如阿里云/腾讯云 OSS/COS + CDN,或支持 Range 的国内托管)。这种情况下浏览器直接拉 mp4 就能边下边播,不需要分片;光有域名、托管还在 GitHub/CF 上,等于白绑。HLS 分片方案则对”不支持 Range”天然免疫,与域名无关。
六、CF Pages 不支持 Range 的替代方案
先交代为什么需要替代方案:CF Pages 对视频不支持 Range 请求(HTTP Range 是浏览器”边下边播”的基础,服务器按需返回字节区间)。浏览器拿不到 Range 响应,只能整包下载 15MB 视频——这就是”下载界面”和”加载慢”的直接根源。不想用 HLS 的话,还有几条路,按推荐度排序:
① Cloudflare R2(最推荐)
- 原理:CF 自家对象存储,原生支持 Range,mp4 单文件直接边下边播;
- 怎么用:CF 控制台开 R2 → 新建 bucket → 上传视频 → 开启公共访问(Public Access)拿到直链 → 播放器改回一行
video.src = mp4 链接; - 成本:免费额度 10GB 存储 + 100 万次读/月,本项目 14.94MB 的视频随便用;
- 优点:和 CF 生态一体,以后有自定义域名直接绑;国内访问速度比 GitHub 好;播放器代码回到最简单的单 mp4;
- 缺点:需要去 CF 开 R2(新用户开 R2 可能要绑卡,超出免费额度才计费);R2 国内速度不算顶级,但明显好于 GitHub。
② jsDelivr CDN(免费捷径)
- 原理:GitHub 仓库文件走 jsDelivr 免费 CDN,支持 Range、国内节点速度快;
- 怎么用:视频传到 GitHub release → 用
https://cdn.jsdelivr.net/gh/用户名/仓库名@版本号/路径/视频.mp4格式的链接; - 限制:单文件 20MB 上限(本项目 14.94MB 刚好卡线);jsDelivr 国内稳定性一般(2022 年出过 DNS 故障,曾大面积失效);
- 结论:作为免费捷径可以试,但不建议当主方案——稳定性和服务政策都是外部不可控因素。
③ Workers 代理模拟 Range(不推荐)
- 原理:写一个 Worker 拦截视频请求,手动解析
Range请求头、按字节区间切片返回; - 问题:每次请求都要回源拉全量视频再切片,带宽浪费严重、首次延迟高;代码和调试成本不小,效果还受 Worker 执行时间限制(免费档 10ms CPU);
- 结论:只有”必须用 CF Pages 域名 + 必须保持单文件 mp4”这种极端约束下才值得考虑。
④ 保持 HLS(本项目现状)
- 原理:ffmpeg 把视频切成 41 个
.ts分片 + 一个playlist.m3u8清单,播放器按清单逐片拉取; - 优势:对”不支持 Range”天然免疫——每个分片都是完整小文件,服务器直接返回即可,Range 根本不在依赖链里;行业标准做法(YouTube、B 站、Netflix 都是分片流);已双平台实测正常;
- 缺点:代码比单 mp4 复杂一点(多一个 hls.js 和一批分片文件),但对本项目来说这个复杂度完全可接受。
我的建议:
- 想彻底简化代码 → 方案①(R2):视频传上去开公共访问,播放器改回一行
video.src = mp4,hls.js 和 41 个分片全删;R2 在 Cloudflare 生态内,以后自定义域名直接绑,国内速度比 GitHub 好; - 不想动任何东西 → 维持 HLS 现状:已经解决你的问题,是行业标准做法,零成本;
- jsDelivr 当免费捷径可以试,但稳定性要打个问号,别当主方案;Workers 模拟 Range 复杂度高收益低,直接跳过。
七、总结
- 问题本质:mp4 单文件播放依赖服务器支持 Range;CF Pages 不支持、GitHub Pages 线路慢,两条路都受限;
- 压缩解决不了播放架构问题,但压缩本身依然重要——480p / 14.94MB 是清晰度与体积的平衡点;
- HLS 分片是行业标准做法:把视频切成小分片,播放器按需拉取,绕开 Range 限制,YouTube / B 站 / Netflix 都是这个思路;
- 域名只管门面:自定义域名不改变底层服务器能力,除非同时换到支持 Range 且国内快的托管;
- 兜底链路:HLS → mp4 → 备用源 → 图片壁纸,任何网络下都有内容可播。
文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!













