Chrome 中 WebRTC 视频不显示:getUserMedia 诊断指南

2016 年原文遇到的是一个真实的迁移差异:摄像头预览在本机可用,部署后却因不安全来源失效。但“给服务器加证书”不是今天所有黑屏问题的答案。当前排查应把问题分成五层:安全上下文、API/权限、设备与约束、媒体元素播放、嵌入与部署策略

本文于 2026 年 9 月 1 日重写为最小、可复现的诊断流程。旧文中的 StartSSL 推荐和 goo.gl 跳转不在维护指南中;有限脱敏的完整历史正文保存在文末。示例只在本地预览摄像头,不录制、不上传,也不建立 WebRTC 对等连接。

一分钟分层检查

先在出现故障的同一页面、同一框架层级、同一浏览器配置中检查:

  1. 地址栏是否是有效的 https://,且 window.isSecureContext === true
  2. navigator.mediaDevicesnavigator.mediaDevices.getUserMedia 是否存在?
  3. 页面是顶层文档还是 <iframe>?如果是框架,父页面是否正确委派摄像头/麦克风?
  4. 请求是否由清楚的用户操作触发,浏览器站点权限和操作系统隐私设置是否允许?
  5. getUserMedia() 是拒绝、一直等待,还是已返回带 live 视频轨的 MediaStream
  6. 成功后是否用 video.srcObject = stream 绑定,并处理了 video.play() 的 Promise?
  7. 页面、脚本、框架、信令端点是否存在 TLS、重定向或混合内容错误?

不要只看“画面是黑的”。在 DevTools Console/Network/Security 面板记录页面 URL、isSecureContext、错误的 name、发生步骤和浏览器版本,但不要把设备标签、完整错误消息、IP 或原始权限状态发送到公共日志。

安全上下文:生产环境用 HTTPS

`getUserMedia()`是强权限功能,只能在安全上下文使用。不安全页面中,navigator.mediaDevices 可能是 undefined,因此甚至不会得到一次正常的权限请求。

http://localhosthttp://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 exactmin、设备 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 视频轨,元素尺寸/元数据正常 srcObjectplay()、CSS 和生命周期
HTTPS 页面加载 HTTP 脚本/iframe/信令 Console/Network 显示阻止或失败 全链改为 HTTPS/WSS,清缓存重测

每次只改变一个变量。记录浏览器/版本、操作系统、设备类型、顶层/iframe、URL scheme、isSecureContext、约束、错误名和结果;对外分享前删除域名中的私人路径、设备标签与账号信息。

发布与隐私验收

  • 真实部署页及其所有祖先是否为有效 HTTPS,isSecureContext 是否为 true?
  • API 特性检测是否在调用前完成,失败时是否给出可操作而非误导性提示?
  • 摄像头与麦克风是否分别按最小需要、经明确点击请求?
  • 是否区分四类核心异常,并从宽约束开始?
  • iframe 是否只向精确、受控来源委派所需功能,没有通配符?
  • 成功流是否通过 srcObject 绑定,play() 是否被等待并处理,CSS/轨道状态是否检查?
  • 所有退出路径是否停止轨道并清空 srcObject
  • 是否没有未披露的录制/上传、原始设备标识日志或无期限保留?
  • TLS、混合内容、CSP、缓存、信令与浏览器测试矩阵是否全部通过?

参考资料

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

Leave a Reply