2016 年原文遇到的是一个真实的迁移差异:摄像头预览在本机可用,部署后却因不安全来源失效。但“给服务器加证书”不是今天所有黑屏问题的答案。当前排查应把问题分成五层:安全上下文、API/权限、设备与约束、媒体元素播放、嵌入与部署策略。
本文于 2026 年 9 月 1 日重写为最小、可复现的诊断流程。旧文中的 StartSSL 推荐和 goo.gl 跳转不在维护指南中;有限脱敏的完整历史正文保存在文末。示例只在本地预览摄像头,不录制、不上传,也不建立 WebRTC 对等连接。
Table of Contents
一分钟分层检查
先在出现故障的同一页面、同一框架层级、同一浏览器配置中检查:
- 地址栏是否是有效的
https://,且window.isSecureContext === true? navigator.mediaDevices和navigator.mediaDevices.getUserMedia是否存在?- 页面是顶层文档还是
<iframe>?如果是框架,父页面是否正确委派摄像头/麦克风? - 请求是否由清楚的用户操作触发,浏览器站点权限和操作系统隐私设置是否允许?
getUserMedia()是拒绝、一直等待,还是已返回带live视频轨的MediaStream?- 成功后是否用
video.srcObject = stream绑定,并处理了video.play()的 Promise? - 页面、脚本、框架、信令端点是否存在 TLS、重定向或混合内容错误?
不要只看“画面是黑的”。在 DevTools Console/Network/Security 面板记录页面 URL、isSecureContext、错误的 name、发生步骤和浏览器版本,但不要把设备标签、完整错误消息、IP 或原始权限状态发送到公共日志。
安全上下文:生产环境用 HTTPS
`getUserMedia()`是强权限功能,只能在安全上下文使用。不安全页面中,navigator.mediaDevices 可能是 undefined,因此甚至不会得到一次正常的权限请求。
http://localhost、http://127.0.0.1 和其他回环来源可被浏览器视为“潜在可信”,这是本机开发例外,不代表 http://192.168.x.x、局域网主机名或公网 HTTP 可用于部署。file:// 的来源与权限行为也不适合作为生产验证。始终用 `window.isSecureContext`检测实际结果。
生产证书不必付费购买。可以使用托管商自动管理的证书或 Let’s Encrypt 的 ACME 流程,关键是证书与主机名匹配、在有效期内、链完整并能自动续期。不要绕过证书警告,也不要把仅由开发机信任的自签名证书当作公共部署方案。
最小可复现页面
把下面文件放在独立的 HTTPS 测试路径,先不要加载框架、分析脚本、录制库或信令代码。它只请求摄像头;应用确实需要麦克风且已向用户说明时,才把 audio: false 改为 audio: true。
<!doctype html>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Camera preview diagnostic</title>
<video id="preview" autoplay muted playsinline></video>
<p>
<button id="start" type="button">Start camera</button>
<button id="stop" type="button" disabled>Stop camera</button>
</p>
<pre id="status" role="status" aria-live="polite">Idle</pre>
<script type="module">
const preview = document.querySelector("#preview");
const startButton = document.querySelector("#start");
const stopButton = document.querySelector("#stop");
const status = document.querySelector("#status");
let activeStream = null;
function setStatus(message) {
status.textContent = message;
}
function describeCaptureError(error) {
switch (error.name) {
case "NotAllowedError":
return "Permission, secure-context, or Permissions Policy failure.";
case "NotFoundError":
return "No requested camera or microphone was found.";
case "NotReadableError":
return "The device is busy or failed at the browser, OS, or hardware layer.";
case "OverconstrainedError":
return `No device satisfies constraint: ${error.constraint || "unknown"}.`;
default:
return "Capture failed; inspect the error name and the failing layer.";
}
}
function stopMedia() {
if (activeStream) {
activeStream.getTracks().forEach((track) => track.stop());
activeStream = null;
}
preview.srcObject = null;
startButton.disabled = false;
stopButton.disabled = true;
}
async function startMedia() {
if (!window.isSecureContext) {
setStatus("Blocked: this is not a secure context.");
return;
}
if (!navigator.mediaDevices ||
typeof navigator.mediaDevices.getUserMedia !== "function") {
setStatus("Blocked: getUserMedia is unavailable in this context/browser.");
return;
}
startButton.disabled = true;
setStatus("Waiting for the browser permission decision…");
try {
activeStream = await navigator.mediaDevices.getUserMedia({
video: true,
audio: false,
});
} catch (error) {
setStatus(`${error.name}: ${describeCaptureError(error)}`);
startButton.disabled = false;
return;
}
preview.srcObject = activeStream;
try {
await preview.play();
} catch (error) {
stopMedia();
setStatus(`Playback failed: ${error.name}.`);
return;
}
const settings = activeStream.getVideoTracks()[0]?.getSettings();
stopButton.disabled = false;
setStatus(`Started ${settings?.width ?? "?"}×${settings?.height ?? "?"}.`);
}
startButton.addEventListener("click", startMedia);
stopButton.addEventListener("click", () => {
stopMedia();
setStatus("Stopped; all tracks released.");
});
window.addEventListener("pagehide", stopMedia);
</script>
点击按钮触发权限请求,便于解释用途并满足播放的用户交互条件。muted 只让本地 <video> 静音,不等于关闭采集轨;playsinline 避免部分移动浏览器强制全屏;srcObject 直接接收 MediaStream。代码等待 `play()`并在失败时释放轨道。
按错误名称分类,不要解析错误文案
浏览器的 message 可能本地化或变化,稳定的第一分类是 DOMException.name:
| 名称 | 常见含义 | 下一步 |
|---|---|---|
NotAllowedError |
用户拒绝/忽略后被拒、站点或 OS 阻止、不安全上下文、框架未获策略委派 | 依次检查安全上下文、框架策略、浏览器站点权限、OS 隐私开关;不要反复弹窗 |
NotFoundError |
没有请求类型的设备,或没有满足基本请求的轨 | 先用 { video: true, audio: false };核对摄像头连接与系统识别 |
NotReadableError |
权限可能已给出,但设备被占用,或浏览器/OS/硬件无法读取 | 关闭其他占用应用,重新插拔/选择设备,重启浏览器;在系统相机应用交叉测试 |
OverconstrainedError |
exact、min、设备 ID 等硬约束无设备可满足 |
删除硬约束后逐项添加;只在本地显示 error.constraint |
TypeError/API 缺失 |
约束为空/全为 false,或不安全上下文中 API 不可见 |
先做特性和安全上下文检测,再检查约束对象 |
OverconstrainedError 可能在授权前暴露设备能力差异,形成指纹面。不要用一系列硬约束探测用户设备,也不要把 constraint、设备 ID 或标签上传作分析数据。用户也可能不处理权限提示,此时 Promise 可以持续等待;界面应保持“等待决定”,而不是谎报超时或不停重试。
权限请求与隐私同意不是一回事
只在用户点击明确的“开启摄像头/麦克风”控件后请求,并在按钮附近说明用途、是否录制、是否发送给他人以及如何停止。先只请求必要轨道:预览只需摄像头就用 audio: false,不要为了“以后可能用到”提前索取麦克风。
浏览器许可只允许页面访问设备,不等于用户同意录制、上传、识别或长期保存。若产品增加这些行为,需要单独、明确的告知与同意,说明目的、接收方、保留期、删除方式和实时指示。不要隐藏浏览器的摄像头/麦克风状态提示。
离开页面、切换应用路由、结束通话或用户点击停止时,对每条轨调用 track.stop() 并把 video.srcObject 设为 null。仅设置 track.enabled = false 是暂停输出,不一定释放设备。设备标签和稳定 ID 可能具有识别性;只在必要且获准后枚举,并最小化日志。
<iframe> 需要两层明确委派
跨源框架中的摄像头页面必须与所有祖先页面处于安全上下文,而且顶层页面要用 HTTP Permissions-Policy 和 iframe 的 allow 属性委派功能。下面只允许自身与一个精确的受控来源,不使用 *:
Permissions-Policy: camera=(self "https://camera.example"), microphone=(self "https://camera.example")
<iframe
src="https://camera.example/capture"
allow="camera; microphone"
></iframe>
allow 只能在响应头允许范围内进一步收紧,不能覆盖更严格的头策略。若不需要麦克风,就从两处删除 microphone。策略阻止时通常得到 NotAllowedError,甚至不会出现权限提示。参见 MDN 的 `camera` 指令、`iframe.allow`以及 W3C Permissions Policy。
带 sandbox 且没有合适来源身份的框架也不能正常请求设备。不要为了让不受信任的第三方内容访问摄像头而随意放宽 sandbox;只向你控制并审计过的来源委派。
已拿到流但仍黑屏
如果 getUserMedia() 成功,权限和设备层大体已通过,接下来检查媒体元素:
stream.getVideoTracks().length应大于 0,轨道的readyState应为live,且没有被意外stop()。- 使用
video.srcObject = stream,不要继续采用旧式供应商前缀或为MediaStream生成对象 URL。 - 预览元素用
autoplay muted playsinline,并检查play()返回的 Promise;有声自动播放更容易被阻止。 - 检查 CSS:元素不能是零尺寸、
display:none、被遮挡或完全透明。监听loadedmetadata,查看videoWidth/videoHeight是否变为非零。 - 区分轨道
enabled/muted/readyState与<video>的muted。后者只是本地扬声器行为。 - 在干净测试页可用、集成页不可用时,逐个恢复框架、路由生命周期、状态管理和第三方脚本,找出谁覆盖了
srcObject或提前停止轨道。
MDN 的 `srcObject`和自动播放指南说明了当前绑定与播放行为。
约束从宽到严
第一次诊断使用 { video: true, audio: false }。确认成功后再添加 facingMode、理想分辨率或帧率,优先使用 ideal,避免一开始就使用 exact/min。每次只添加一个约束,并在失败时记录 error.name 与本地的 error.constraint。
若应用必须选择设备,应在用户已经明确开启设备流程后调用 enumerateDevices(),展示浏览器提供的选项,而不是把稳定 deviceId 写死在代码或 URL 中。设备更换、权限清除和浏览器隐私策略都会使旧 ID 失效。
TLS、混合内容与部署差异
“页面地址是 HTTPS”仍不足以证明部署完整:
- 证书必须覆盖实际主机名,链完整、未过期;不要让用户点击穿越证书警告。
- 所有祖先框架都应可信;脚本、模块、worker、iframe 和 WebRTC 信令应使用
https:///wss://。HTTPS 页面中的 HTTP 活动资源会被浏览器阻止,参见 MDN Mixed content。 - 检查是否从 HTTPS 重定向回 HTTP、反向代理是否生成错误 scheme/host、CSP 是否阻止脚本或
connect-src、部署是否仍引用旧缓存文件。 - 把“本地摄像头采集”与“信令/对等连接”分开测试。预览成功但通话失败通常属于网络、ICE/TURN、信令或 CSP,而不是
<video>获取摄像头的问题。 - 在真实部署 URL、无痕/新配置文件和支持矩阵中的浏览器测试;不要用关闭 Web 安全或把不安全来源强制标成安全的启动参数掩盖问题。
测试矩阵
| 场景 | 预期 | 若不符合 |
|---|---|---|
| 顶层有效 HTTPS,宽松视频约束 | 点击后出现提示;允许后显示预览 | 按错误名查浏览器/OS/设备 |
本机 http://localhost |
支持的浏览器可作为开发例外,isSecureContext 为 true |
不要推断 LAN/生产 HTTP 也可用 |
http://192.168.x.x 或公网 HTTP |
API 不可用或请求被拒 | 部署有效 HTTPS,不使用绕过标志 |
跨源 iframe,无 header/allow |
无提示并以 NotAllowedError 失败 |
用精确来源完成两层委派 |
| 跨源 iframe,正确委派且两端 HTTPS | 用户仍需明确授权;允许后预览 | 查祖先、sandbox、响应头和站点权限 |
| 无摄像头/请求了不存在的轨 | NotFoundError |
调整请求类型,检查系统设备 |
| 摄像头被其他应用独占/系统读取失败 | NotReadableError |
释放占用并用系统相机交叉测试 |
| 使用设备不支持的硬约束 | OverconstrainedError |
删除硬约束,再逐项添加 |
| 采集成功但视频空白 | 流有 live 视频轨,元素尺寸/元数据正常 |
查 srcObject、play()、CSS 和生命周期 |
| HTTPS 页面加载 HTTP 脚本/iframe/信令 | Console/Network 显示阻止或失败 | 全链改为 HTTPS/WSS,清缓存重测 |
每次只改变一个变量。记录浏览器/版本、操作系统、设备类型、顶层/iframe、URL scheme、isSecureContext、约束、错误名和结果;对外分享前删除域名中的私人路径、设备标签与账号信息。
发布与隐私验收
- 真实部署页及其所有祖先是否为有效 HTTPS,
isSecureContext是否为 true? - API 特性检测是否在调用前完成,失败时是否给出可操作而非误导性提示?
- 摄像头与麦克风是否分别按最小需要、经明确点击请求?
- 是否区分四类核心异常,并从宽约束开始?
- iframe 是否只向精确、受控来源委派所需功能,没有通配符?
- 成功流是否通过
srcObject绑定,play()是否被等待并处理,CSS/轨道状态是否检查? - 所有退出路径是否停止轨道并清空
srcObject? - 是否没有未披露的录制/上传、原始设备标识日志或无期限保留?
- TLS、混合内容、CSP、缓存、信令与浏览器测试矩阵是否全部通过?
参考资料
- MDN:MediaDevices.getUserMedia()、Secure contexts
- MDN:`HTMLMediaElement.srcObject`、`play()`、Autoplay guide
- MDN:Permissions-Policy `camera`、`HTMLIFrameElement.allow`
- MDN:Mixed content
- W3C:Media Capture and Streams、Permissions Policy
- Let’s Encrypt:Getting Started
2016 年历史原文(仅供出处核对,不要照做)
以下是源导出的完整可见正文,除两处有限替换外未改写:旧 goo.gl 短链接(1 处)可能重定向并产生关联/跟踪,替换为 [obsolete shortened URL redacted];失效的 StartSSL 服务地址(1 处)可能易手或误导,替换为 [obsolete certificate-service URL redacted]。原字符串仅保留在 source_export 与 Git 出处历史中。旧文的付费证书二分法、自签名公共部署暗示和 StartSSL 推荐均已被维护指南否定;存档是文本证据,不是当前操作说明。
Google 浏览器 WebRTC 的 getUserMeida 在本地工作正常,但上传到服务期访问,发现 Video 标签没有视频输入,F12 查看前端报错:
getUserMedia() no longer works on insecure origins. To use this feature, you should consider switching your application to a secure origin, such as HTTPS. See [obsolete shortened URL redacted] for more details.
最新版的 Chrome 浏览器需要 https 访问才能调用 getUserMedia。
解决办法是购买https证书,或者自制证书让服务器支持 https 访问。
免费申请的网站:
[obsolete certificate-service URL redacted]
https://letsencrypt.org
