有次客户反馈:"视频在你们网页上播不了,一片黑。"
我把文件下载下来用 VLC、PotPlayer、ffprobe 全查了一遍——编码正常、分辨率正常、有音轨、能完整解码。本地播放器一点问题没有。
最后定位到的问题跟文件毫无关系:nginx 给
.m3u8返回的 Content-Type 是text/plain,hls.js 直接拒绝解析。这类"文件没问题但网页播不了"的情况,我后来遇到过十几种,全都是环境问题。这篇把它们整理成一份清单。
TL;DR:浏览器原生
<video>只支持 MP4(H.264+AAC)、WebM(VP8/VP9+Opus),HLS 只有 Safari 原生支持——Chrome/Firefox 播 HLS 必须上 hls.js(基于 MSE)。四个最常见的坑:MIME 类型(m3u8/mp4/ts 的 Content-Type 错了播放器直接拒绝)、CORS(跨域要Access-Control-Allow-Origin,带凭证时不能用*)、自动播放策略(浏览器只允许静音自动播放)、HTTPS 混合内容(HTTPS 页面加载 HTTP 资源会被拦截)。排查顺序:先看网络面板(资源有没有下来、状态码、Content-Type),再看控制台(播放器的错误日志)。
目录
- 一、先分清"文件问题"还是"环境问题"
- 二、浏览器原生能播什么
- 三、HLS:Safari 能播,Chrome 不行
- 四、MIME 类型:最容易被忽略
- 五、CORS:跨域的那些配置
- 六、自动播放策略
- 七、HTTPS 与混合内容
- 八、Range 请求与拖动
- 九、对症排查清单
- 十、坑清单
一、先分清"文件问题"还是"环境问题"
三步定位:
| 步骤 | 做法 | 结论 |
|---|---|---|
| 1 | 本地播放器(VLC)能不能播? | 不能 → 文件/编码问题(查编码、兼容性) |
| 2 | 换浏览器试试(Chrome / Safari / Firefox) | 某个能播 → 兼容性/格式支持问题 |
| 3 | 打开 DevTools 的 Network 面板 | 看资源是否 200、Content-Type 是否正确 |
最常见的分界线:Safari 能播、Chrome 不能 → 99% 是 HLS 没上 hls.js(因为 Safari 原生支持 HLS)。
二、浏览器原生能播什么
| 容器 | 编码 | 支持 |
|---|---|---|
| MP4 | H.264 + AAC | 所有浏览器(最通用) |
| WebM | VP8/VP9 + Opus/Vorbis | Chrome/Firefox/Edge 好,Safari 部分 |
| Ogg | Theora + Vorbis | 基本可以不用考虑 |
| HLS (m3u8) | H.264/AAC | 只有 Safari / iOS 原生 |
| DASH (mpd) | 任意 | 都需要 JS 库 |
所以 Web 交付的默认格式:
- 小文件/点播单文件 → MP4(H.264 + AAC);
- 长视频/需要自适应 → HLS + hls.js(或者 DASH + dash.js)。
三、HLS:Safari 能播,Chrome 不行
原因:Safari(和 iOS)把 HLS 做进了原生播放器;Chrome/Firefox 没有,但它们提供了 MSE(Media Source Extensions) API,可以用 JS 自己实现 HLS 解析——这就是 hls.js 做的事。
接入 hls.js:
<video id="v" controls></video>
<script src="https://cdn.jsdelivr.net/npm/hls.js@1"></script>
<script>
const video = document.getElementById('v');
const url = 'https://cdn.example.com/vod/master.m3u8';
if (video.canPlayType('application/vnd.apple.mpegurl')) {
// Safari:原生播
video.src = url;
} else if (Hls.isSupported()) {
// Chrome/Firefox:用 hls.js
const hls = new Hls({
// 常用配置
enableWorker: true, // 解析放到 Web Worker,不卡主线程
lowLatencyMode: false, // 低延迟模式(LL-HLS 才开)
backBufferLength: 90, // 保留多少秒的已播缓冲(省内存)
maxBufferLength: 30, // 最大缓冲
});
hls.loadSource(url);
hls.attachMedia(video);
hls.on(Hls.Events.ERROR, (evt, data) => {
console.error('hls error', data.type, data.details, data.fatal);
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();
}
}
});
}
</script>
错误处理一定要写——否则网络一抖播放器就死在那里,用户看到的是"转圈"。
播放器封装库(如果不想直接用 hls.js):
| 库 | 说明 |
|---|---|
| video.js | 老牌、插件多、体积大 |
| DPlayer | 中文社区常用,支持弹幕 |
| xgplayer(西瓜播放器) | 字节开源,功能全 |
| Plyr | 轻量、UI 好看 |
| hls.js 直接用 | 最轻,控制最细 |
四、MIME 类型:最容易被忽略
这就是我那次踩的坑。nginx 默认不认识 .m3u8 和 .m4s,会按 application/octet-stream 或者 text/plain 返回,hls.js 会拒绝解析。
正确配置:
http {
include /etc/nginx/mime.types;
# 补充 HLS / DASH 的类型
types {
application/vnd.apple.mpegurl m3u8;
audio/mpegurl m3u8; # 有些播放器认这个
video/mp2t ts;
video/mp4 mp4 m4s;
application/dash+xml mpd;
}
server {
location /vod/ {
# 确保 types 生效
default_type application/octet-stream;
...
}
}
}
各文件的正确 Content-Type:
| 文件 | Content-Type |
|---|---|
.m3u8 |
application/vnd.apple.mpegurl 或 audio/mpegurl |
.ts |
video/mp2t |
.m4s / .mp4 |
video/mp4 |
.mpd |
application/dash+xml |
验证:
curl -I https://cdn.example.com/vod/master.m3u8 | grep -i content-type
# content-type: application/vnd.apple.mpegurl
五、CORS:跨域的那些配置
如果播放器和视频资源不在同一个域(CDN 场景必然跨域),需要 CORS 头。
location /vod/ {
add_header Access-Control-Allow-Origin "https://www.example.com" always;
add_header Access-Control-Allow-Methods "GET, HEAD, OPTIONS" always;
add_header Access-Control-Allow-Headers "Range" always;
# 需要暴露给 JS 的响应头(比如要读 Content-Length)
add_header Access-Control-Expose-Headers "Content-Length, Content-Range" always;
# 处理预检请求
if ($request_method = OPTIONS) {
return 204;
}
}
几个坑:
- 带凭证(cookies / credentials)时,
Access-Control-Allow-Origin不能用*,必须写具体域名。如果需要支持多个域名,要按$http_origin动态判断并回显; add_header不会继承:如果在location里用了add_header,上层的add_header会被覆盖——每个层级要写全(这是 nginx 的经典坑);always参数:确保 4xx/5xx 响应也带 CORS 头,否则错误时 JS 看不到具体错误;- 如果是 Range 请求(拖动进度条),
OPTIONS预检可能带Access-Control-Request-Headers: range,要在Allow-Headers里包含Range。
六、自动播放策略
浏览器的规则(Chrome 最严格):
有声音的自动播放会被阻止,除非用户已经与页面有过交互。
解决办法:
<video autoplay muted playsinline></video>
muted:静音的自动播放是允许的;playsinline:iOS 上防止强制全屏播放(iOS 10+ 需要)。
如果一定要有声音:先静音自动播放,然后提示用户点击"开启声音"——在用户交互后再 video.muted = false。
video.play().then(() => {
console.log('播放成功');
}).catch(err => {
// 被自动播放策略拦了
showPlayButton(); // 显示一个"点击播放"按钮
});
play() 返回 Promise——一定要 catch,否则你不知道播放失败的原因。
七、HTTPS 与混合内容
规则:HTTPS 页面里加载 HTTP 资源会被浏览器拦截(混合内容,mixed content)。
表现:控制台出现
Mixed Content: The page at 'https://...' was loaded over HTTPS but requested an insecure video 'http://...'. This request has been blocked.
解决:所有资源(包括视频、封面、字幕)都用 HTTPS。
CDN 回源要不要 HTTPS? CDN 到源站可以用 HTTP(在内网/专线),但CDN 对外必须是 HTTPS。
八、Range 请求与拖动
拖动进度条依赖 Range 请求:浏览器发 Range: bytes=xxx-,服务器要返回 206 Partial Content。
nginx 默认支持(静态文件模块会处理),但如果:
- 用了
proxy_pass到应用服务器 → 要确保上游支持 Range 并正确传递; - 用了应用视图自己返回文件(比如鉴权后转发)→ 必须自己处理 Range 头(前面 X-Accel-Redirect 那篇讲过,用 nginx 的 X-Accel-Redirect 最省事);
- 用了签名 URL 且签名过期 → 拖动时重新请求可能 403。
检查:
curl -H "Range: bytes=0-1023" -I https://cdn.example.com/vod/v0/seg_001.m4s
# 应该返回 206 和 Content-Range
MP4 的 faststart 也在这里起作用——没有 faststart 的 MP4,浏览器要下载完整个文件才能播(前面讲过)。
九、对症排查清单
| 症状 | 可能原因 | 怎么查 |
|---|---|---|
| 完全黑屏,无声音 | 资源没加载(404/CORS/MIME) | Network 面板看状态码和 Content-Type |
| 转圈不播 | 网络慢/CDN/分片 404 | Network 面板看分片请求 |
| 有声音没画面 | 视频编码不支持/解码失败 | 换浏览器、看编码 |
| Safari 能播 Chrome 不能 | HLS 没上 hls.js | 检查是否加载了 hls.js |
| iOS 上不能播 | HEVC/编码兼容、playsinline 缺失 | 用 H.264 |
| 拖动后卡住 | Range 不支持 / 关键帧问题 | curl 测 Range |
| 403 | 防盗链/签名过期 | 看 Referer / URL 参数 |
| 首次加载慢 | 没 faststart / 起播档位高 | 检查 moov 位置 |
| 播放几秒后停 | 分片 URL 错误 / 网络中断 | 看后续分片请求 |
| 移动端全屏播放 | 缺 playsinline |
加属性 |
调试工具:
- Chrome 的
chrome://media-internals:能看到播放器内部的详细状态和错误(很有用,但界面不友好); - hls.js 的日志:
new Hls({debug: true})打开调试日志; - Network 面板:过滤
m3u8和m4s,看请求序列和状态; - ffprobe 确认文件本身:排除文件问题。
十、坑清单
- 以为 MP4 在所有浏览器都能播 → 编码要对(H.264+AAC)。HEVC 在 Chrome 上普遍不行。
- HLS 只用原生 video → Chrome/Firefox 播不了。上 hls.js。
- MIME 类型不对 → hls.js 拒绝解析。配 nginx types。
- CORS 用了
*又要带凭证 → 报错。写具体域名。 add_header在 location 里覆盖上层 → 头丢了。每个层级写全。- OPTIONS 预检没处理 → 返回 405 或者被应用框架拦截。
return 204。 - 自动播放没静音 → 被拦。
muted+playsinline。 - HTTPS 页面加载 HTTP 资源 → 混合内容被拦。全站 HTTPS。
- 忘了
playsinline→ iOS 强制全屏。 - Range 请求没支持 → 拖不动进度条。
- MP4 没 faststart → 要下完才能播。
- hls.js 错误没处理 → 网络抖一下就卡死。加 ERROR 监听和重试。
- 起播档位设太高 → 首帧慢。用低码率起播再切。
- 封面图跨域 → Canvas 取像素时报错(如果播放器要用 canvas 处理)。
- 没做浏览器能力检测 → 在老浏览器上白屏。
Hls.isSupported()判断后给降级提示。 - 字幕(VTT)也跨域 → 字幕轨同样需要 CORS。
- CDN 缓存了错误响应 → 修完 MIME 还是错的。清 CDN 缓存。
最后说说这类问题的共性。
"文件没问题但播不了"之所以难查,是因为责任边界模糊——做视频的人觉得是前端的问题,前端觉得是视频的问题,运维觉得是 CDN 的问题。而实际上,网页播放是一条跨多个系统的链路:
你的转码产物 → 存储/CDN 配置 → nginx 的 MIME/CORS/Range → 浏览器的能力支持 → 播放器库的实现 → 用户的网络
每一环都可能出问题,而且每一环的表现都是"播不了"。
所以我的排查方法永远是从两端往中间夹:
- 从文件端:ffprobe + VLC 确认文件本身没问题(排除第一环);
- 从浏览器端:Network 面板看实际请求和响应(能看到中间几环);
- 剩下的就是播放器实现的问题。
这个方法比"凭经验猜"快得多——因为它能立刻把问题范围缩小到某一环。
还有一个习惯值得分享:把常见的配置(MIME、CORS、Range、缓存策略)做成一份 nginx 配置模板,新项目直接套。这类问题几乎全是重复劳动——第一次查三个小时,之后只要套模板就再也不会犯。我们现在的 deploy/ 目录里就躺着这么一份 media.conf,它已经帮我们省掉了至少十次同类排查。