
MediaDevices.getUserMedia() 会提示用户给予使用媒体输入的许可媒体输入会产生一个MediaStream里面包含了请求的媒体类型的轨道。此流可以包含一个视频轨道来自硬件或者虚拟视频源比如相机、视频采集设备和屏幕共享服务等等、一个音频轨道同样来自硬件或虚拟音频源比如麦克风、A/D 转换器等等也可能是其他轨道类型。它返回一个 Promise 对象成功后会resolve回调一个 MediaStream 对象。若用户拒绝了使用权限或者需要的媒体源不可用promise会reject回调一个PermissionDeniedError或者NotFoundError.下面我提供一个完整的、可运行的方案基于MediaDevices API获取摄像头画面 face-api.js基于 TensorFlow.js 的人脸识别库进行人脸检测与关键点定位。能力技术摄像头调用navigator.mediaDevices.getUserMedia()人脸检测face-api.jsSSD MobileNet / Tiny Face Detector人脸关键点face-api.js68 点面部特征定位人脸识别/比对face-api.jsFaceMatcher 欧氏距离1. 摄像头调用const stream await navigator.mediaDevices.getUserMedia({ video: { facingMode: user, // 前置摄像头移动端 // facingMode: environment, // 后置摄像头 width: { ideal: 640 }, height: { ideal: 480 } }, audio: false }); video.srcObject stream;注意事项必须在HTTPS或localhost下才能调用摄像头移动端需要用playsinline属性防止全屏播放facingMode: user为前置environment为后置2. face-api.js 模型说明模型用途大小tinyFaceDetector轻量人脸检测推荐移动端~190KBssdMobilenetv1更精确的人脸检测~5.4MBfaceLandmark68Net68 点面部关键点定位~350KBfaceRecognitionNet人脸特征向量提取128 维~6.2MBfaceExpressionNet表情识别happy/sad/angry 等~320KBageGenderNet年龄与性别预估~420KB3. 人脸识别身份比对如果需要身份识别不只是检测可以这样扩展// 注册已知人脸 async function registerFace(imageElement, name) { const detection await faceapi .detectSingleFace(imageElement) .withFaceLandmarks() .withFaceDescriptor(); if (detection) { return new faceapi.LabeledFaceDescriptors(name, [detection.descriptor]); } return null; } // 创建匹配器 const labeledDescriptors await Promise.all([ registerFace(document.getElementById(photo1), 张三), registerFace(document.getElementById(photo2), 李四), ]); const matcher new faceapi.FaceMatcher(labeledDescriptors, 0.6); // 实时匹配 const result matcher.findBestMatch(liveDescriptor); console.log(result.label); // 张三 或 unknown console.log(result.distance); // 越小越匹配4. 表情 年龄识别扩展// 检测时同时获取表情和年龄 const results await faceapi .detectAllFaces(video, options) .withFaceLandmarks() .withFaceExpressions() .withAgeAndGender(); results.forEach(result { const { age, gender, genderProbability } result; const expressions result.expressions; const dominant expressions.asSortedArray()[0]; // 最大可能的表情 });权限与兼容性项目说明HTTPS摄像头 API 强制要求安全上下文iOS Safari需要playsinline属性不支持facingMode约束切换前后摄像头微信内置浏览器需要使用微信 JS-SDK 的wx.chooseMedia权限弹窗首次调用浏览器会弹出权限询问拒绝后需用户手动开启性能移动端建议用TinyFaceDetectorPC 端可用SsdMobilenetv1微信 / 小程序方案差异如果是小程序环境非普通 H5需要改用// 微信 JS-SDK公众号网页 wx.chooseMedia({ count: 1, mediaType: [video], sourceType: [camera], camera: front, success(res) { const tempFilePath res.tempFiles[0].tempFilePath; // 上传到服务端做人脸识别 } }); // 微信小程序 const ctx wx.createCameraContext(); ctx.start(); // 启动相机组件 ctx.onCameraFrame((frame) { // frame.data 是 ArrayBuffer (RGBA) // 送到 TensorFlow.js 或服务端 API 处理 });iOS Safari 兼容1. API 存在性降级// 优先级标准 API → webkit 前缀 → 包装成 Promise navigator.mediaDevices.getUserMedia // 现代浏览器 navigator.webkitGetUserMedia // Safari 旧版 / Chrome 旧版 navigator.getUserMedia // 更老的版本2. Safari 的video必须属性!-- 缺任何一个都可能在 iOS Safari 上黑屏或全屏 -- video autoplay playsinline webkit-playsinline muted/video // JS 侧也要设置双重保险 video.setAttribute(playsinline, ); video.setAttribute(webkit-playsinline, );3.iOS Safari 约束降级策略// Safari 对某些约束值会直接报 OverconstrainedError // 需要 try-catch 降级 async function safeGetStream() { const attempts [ { video: { facingMode: user, width: { ideal: 1280 }, height: { ideal: 720 } } }, { video: { facingMode: user, width: { ideal: 640 }, height: { ideal: 480 } } }, { video: { facingMode: user } }, { video: true } ]; for (const constraints of attempts) { try { return await getUserMedia(constraints); } catch (e) { if (e.name OverconstrainedError) continue; throw e; } } }完整代码!DOCTYPE html html langzh-CN head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0, user-scalableno titleSafari 摄像头兼容/title link relpreconnect hrefhttps://fonts.googleapis.com link hrefhttps://fonts.googleapis.com/css2?familySyne:wght400;700;800familyDMMono:wght300;400displayswap relstylesheet style :root { --bg: #08080c; --surface: #101016; --accent: #35e8a0; --accent-dim: rgba(53, 232, 160, 0.12); --danger: #f0564a; --warn: #f0b64a; --text: #e8e6e1; --text-muted: #555350; --border: #1e1e24; } * { margin: 0; padding: 0; box-sizing: border-box; } body { background: var(--bg); color: var(--text); font-family: DM Mono, monospace; min-height: 100vh; min-height: -webkit-fill-available; overflow-x: hidden; } /* 噪点纹理 */ body::before { content: ; position: fixed; inset: 0; opacity: 0.03; background: url(data:image/svgxml,%3Csvg viewBox0 0 256 256 xmlnshttp://www.w3.org/2000/svg%3E%3Cfilter idn%3E%3CfeTurbulence typefractalNoise baseFrequency0.9 numOctaves4 stitchTilesstitch/%3E%3C/filter%3E%3Crect width100%25 height100%25 filterurl(%23n)/%3E%3C/svg%3E); pointer-events: none; z-index: 0; } .app { position: relative; z-index: 1; max-width: 680px; margin: 0 auto; padding: 32px 20px; padding-bottom: calc(32px env(safe-area-inset-bottom, 0px)); } header { margin-bottom: 36px; } .badge { display: inline-block; font-size: 10px; letter-spacing: 0.18em; text-transform: uppercase; color: var(--accent); border: 1px solid rgba(53, 232, 160, 0.25); padding: 5px 14px; margin-bottom: 18px; } h1 { font-family: Syne, sans-serif; font-size: clamp(28px, 7vw, 46px); font-weight: 800; line-height: 1.08; letter-spacing: -0.02em; margin-bottom: 14px; } h1 em { font-style: normal; color: var(--accent); } .desc { font-size: 12px; line-height: 1.7; color: var(--text-muted); } /* 诊断面板 */ .diag { background: var(--surface); border: 1px solid var(--border); padding: 20px; margin-bottom: 28px; font-size: 12px; line-height: 2; } .diag-title { font-family: Syne, sans-serif; font-weight: 700; font-size: 13px; letter-spacing: 0.08em; text-transform: uppercase; margin-bottom: 12px; color: var(--text-muted); } .diag-row { display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid var(--border); padding: 4px 0; } .diag-row:last-child { border-bottom: none; } .diag-label { color: var(--text-muted); } .diag-value { font-weight: 400; padding: 2px 10px; font-size: 11px; } .diag-value.ok { color: var(--accent); background: var(--accent-dim); } .diag-value.fail { color: var(--danger); background: rgba(240, 86, 74, 0.1); } .diag-value.warn { color: var(--warn); background: rgba(240, 182, 74, 0.1); } /* 按钮 */ .controls { display: flex; gap: 10px; margin-bottom: 24px; flex-wrap: wrap; } .btn { font-family: DM Mono, monospace; font-size: 11px; letter-spacing: 0.1em; text-transform: uppercase; padding: 14px 28px; border: 1px solid var(--border); background: var(--surface); color: var(--text); cursor: pointer; transition: all 0.2s ease; -webkit-tap-highlight-color: transparent; flex: 1; min-width: 140px; text-align: center; } .btn:active { background: var(--accent); color: var(--bg); border-color: var(--accent); } media (hover: hover) { .btn:hover { border-color: var(--accent); box-shadow: 0 0 24px var(--accent-dim); } } .btn.running { border-color: var(--accent); box-shadow: 0 0 24px var(--accent-dim); position: relative; } .btn.running::after { content: ; width: 6px; height: 6px; background: var(--accent); border-radius: 50%; display: inline-block; margin-left: 8px; animation: blink 1s ease-in-out infinite; } keyframes blink { 0%, 100% { opacity: 1; } 50% { opacity: 0.2; } } /* 视频 */ .video-wrap { position: relative; width: 100%; background: var(--surface); border: 1px solid var(--border); overflow: hidden; margin-bottom: 24px; } .video-wrap video { display: block; width: 100%; height: auto; transform: scaleX(-1); -webkit-transform: scaleX(-1); } .video-wrap canvas { position: absolute; top: 0; left: 0; width: 100%; height: 100%; transform: scaleX(-1); -webkit-transform: scaleX(-1); } /* 十字准星 */ .crosshair { position: absolute; inset: 0; pointer-events: none; display: none; } .crosshair.visible { display: block; } .crosshair::before, .crosshair::after { content: ; position: absolute; background: rgba(53, 232, 160, 0.15); } .crosshair::before { top: 50%; left: 0; right: 0; height: 1px; } .crosshair::after { left: 50%; top: 0; bottom: 0; width: 1px; } /* 空态 */ .empty { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 14px; color: var(--text-muted); font-size: 12px; text-align: center; padding: 20px; } .empty.hidden { display: none; } .empty-icon { width: 40px; height: 40px; border: 1.5px solid var(--text-muted); border-radius: 50%; display: flex; align-items: center; justify-content: center; opacity: 0.4; font-size: 18px; } /* 信息输出 */ .info-panel { background: var(--surface); border: 1px solid var(--border); padding: 16px 20px; font-size: 12px; line-height: 1.9; color: var(--text-muted); min-height: 100px; max-height: 240px; overflow-y: auto; -webkit-overflow-scrolling: touch; } .info-panel::-webkit-scrollbar { width: 3px; } .info-panel::-webkit-scrollbar-thumb { background: var(--accent-dim); } .log-line .ts { color: var(--accent); margin-right: 6px; } .log-line.err { color: var(--danger); } .log-line.ok { color: var(--accent); } .log-line.warn { color: var(--warn); } /* 提示横幅 */ .hint { margin-bottom: 20px; padding: 14px 18px; background: rgba(240, 182, 74, 0.06); border-left: 3px solid var(--warn); font-size: 11px; line-height: 1.8; color: var(--warn); display: none; } .hint.visible { display: block; } /style /head body div classapp header div classbadgeSafari Compatible/div h1摄像头em兼容性/em方案/h1 p classdesc 自动检测浏览器环境适配 Safari / iOS / WKWebViewbr 逐层降级确保最大程度可用。 /p /header !-- 兼容性诊断 -- div classdiag iddiag div classdiag-title环境诊断/div div classdiag-row span classdiag-labelnavigator.mediaDevices/span span classdiag-value idd_mediaDevices检测中.../span /div div classdiag-row span classdiag-labelgetUserMedia/span span classdiag-value idd_getUserMedia检测中.../span /div div classdiag-row span classdiag-labelwebkitGetUserMedia/span span classdiag-value idd_webkit检测中.../span /div div classdiag-row span classdiag-label协议 (Protocol)/span span classdiag-value idd_protocol检测中.../span /div div classdiag-row span classdiag-label浏览器/span span classdiag-value idd_browser检测中.../span /div /div div classhint idhint/div div classcontrols button classbtn idbtnStart启动摄像头/button button classbtn idbtnStop停止/button /div div classvideo-wrap video idvideo autoplay playsinline muted/video canvas idcanvas/canvas div classcrosshair idcrosshair/div div classempty idempty div classempty-icon/div span点击「启动摄像头」br首次使用请允许权限弹窗/span /div /div div classinfo-panel idlog/div /div script // // 1. 环境检测与 API 兼容 // const Compat { // 获取兼容的 getUserMedia 函数 getGetUserMedia() { // 标准 API if (navigator.mediaDevices navigator.mediaDevices.getUserMedia) { return navigator.mediaDevices.getUserMedia.bind(navigator.mediaDevices); } // Safari / 旧版 Chrome 带 webkit 前缀 const legacy navigator.getUserMedia || navigator.webkitGetUserMedia || navigator.mozGetUserMedia || navigator.msGetUserMedia; if (legacy) { // 包装成 Promise 形式 return (constraints) new Promise((resolve, reject) { legacy.call(navigator, constraints, resolve, reject); }); } return null; }, // 获取兼容的 mediaDevices 对象给 face-api 等库用 polyfillMediaDevices() { if (!navigator.mediaDevices) { navigator.mediaDevices {}; } if (!navigator.mediaDevices.getUserMedia) { const getUserMedia this.getGetUserMedia(); if (getUserMedia) { navigator.mediaDevices.getUserMedia getUserMedia; } } // 部分环境缺少 enumerateDevices if (!navigator.mediaDevices.enumerateDevices) { navigator.mediaDevices.enumerateDevices () Promise.resolve([]); } }, // 检测浏览器类型 detectBrowser() { const ua navigator.userAgent; if (/CriOS/i.test(ua)) return Chrome iOS; if (/FxiOS/i.test(ua)) return Firefox iOS; if (/EdgiOS/i.test(ua)) return Edge iOS; if (/Safari/i.test(ua) !/Chrome/i.test(ua)) return Safari; if (/iPhone|iPad|iPod/i.test(ua)) return iOS Other; if (/Chrome/i.test(ua)) return Chrome; if (/Firefox/i.test(ua)) return Firefox; return Unknown; }, // 是否在微信 / 支付宝等 WebView 内 isInAppBrowser() { const ua navigator.userAgent; return /MicroMessenger|AlipayClient|DingTalk|QQBrowser/i.test(ua); }, // 是否安全上下文HTTPS 或 localhost isSecureContext() { return window.isSecureContext || location.protocol https: || location.hostname localhost || location.hostname 127.0.0.1; } }; // // 2. 诊断面板填充 // function runDiagnostics() { const $ (id) document.getElementById(id); // mediaDevices const hasMD !!navigator.mediaDevices; $(d_mediaDevices).textContent hasMD ? 可用 : 不可用; $(d_mediaDevices).className diag-value (hasMD ? ok : fail); // getUserMedia const hasGUM !!(navigator.mediaDevices navigator.mediaDevices.getUserMedia); $(d_getUserMedia).textContent hasGUM ? 可用 : 不可用; $(d_getUserMedia).className diag-value (hasGUM ? ok : fail); // webkitGetUserMedia const hasWebkit !!navigator.webkitGetUserMedia; $(d_webkit).textContent hasWebkit ? 可用 : 不可用; $(d_webkit).className diag-value (hasWebkit ? ok : fail); // 协议 const proto location.protocol; const isSecure Compat.isSecureContext(); $(d_protocol).textContent proto (isSecure ? (安全) : (不安全!)); $(d_protocol).className diag-value (isSecure ? ok : fail); // 浏览器 const browser Compat.detectBrowser(); const inApp Compat.isInAppBrowser(); $(d_browser).textContent browser (inApp ? (内嵌浏览器) : ); $(d_browser).className diag-value (inApp ? warn : ok); // 提示信息 const hint $(hint); if (!isSecure) { hint.innerHTML ⚠ 当前页面不是 HTTPS 协议。Safari 在 HTTP 下不会暴露 mediaDevices API。请部署到 HTTPS 或使用 localhost 访问。; hint.classList.add(visible); } else if (inApp) { hint.innerHTML ⚠ 检测到内嵌浏览器微信/支付宝等其 WebView 不支持 getUserMedia。请在系统浏览器Safari / Chrome中打开此页面。; hint.classList.add(visible); } else if (!hasGUM hasWebkit) { hint.innerHTML ℹ 当前浏览器使用旧版 webkit 前缀 API已自动适配。; hint.classList.add(visible); } } // // 3. 摄像头控制 // let mediaStream null; const video document.getElementById(video); const canvas document.getElementById(canvas); const ctx canvas.getContext(2d); function log(msg, type ) { const el document.getElementById(log); const ts new Date().toLocaleTimeString(zh-CN, { hour12: false }); const line document.createElement(div); line.className log-line type; line.innerHTML span classts[${ts}]/span${msg}; el.appendChild(line); el.scrollTop el.scrollHeight; } async function startCamera() { // 先执行 polyfill Compat.polyfillMediaDevices(); const getUserMedia Compat.getGetUserMedia(); if (!getUserMedia) { log(此浏览器不支持任何 getUserMedia API请更换浏览器, err); return; } if (!Compat.isSecureContext()) { log(非安全上下文 (HTTP)摄像头被浏览器阻止, err); log(解决方案部署到 HTTPS或使用 localhost / 127.0.0.1 访问, warn); return; } try { log(请求摄像头权限...); // ---- Safari 适配要点 ---- const constraints { video: { facingMode: user, width: { ideal: 640, max: 1280 }, height: { ideal: 480, max: 960 } }, audio: false }; // Safari iOS 上部分约束可能不支持做降级 try { mediaStream await getUserMedia(constraints); } catch (e) { if (e.name OverconstrainedError || e.name ConstraintNotSatisfiedError) { log(精简约束参数重试..., warn); mediaStream await getUserMedia({ video: true, audio: false }); } else { throw e; } } // ---- 关键Safari 需要 playsinline ---- video.setAttribute(playsinline, ); video.setAttribute(webkit-playsinline, ); video.setAttribute(muted, ); video.setAttribute(autoplay, ); video.srcObject mediaStream; // Safari 有时需要显式 play() await video.play(); canvas.width video.videoWidth; canvas.height video.videoHeight; document.getElementById(empty).classList.add(hidden); document.getElementById(crosshair).classList.add(visible); document.getElementById(btnStart).classList.add(running); log(摄像头已启动: ${video.videoWidth}x${video.videoHeight}, ok); // 简单的帧绘制演示 drawLoop(); } catch (err) { handleCameraError(err); } } function stopCamera() { if (mediaStream) { mediaStream.getTracks().forEach(t t.stop()); mediaStream null; } video.srcObject null; ctx.clearRect(0, 0, canvas.width, canvas.height); document.getElementById(empty).classList.remove(hidden); document.getElementById(crosshair).classList.remove(visible); document.getElementById(btnStart).classList.remove(running); log(摄像头已停止); } function handleCameraError(err) { const name err.name || UnknownError; const msg err.message || ; const messages { NotAllowedError: 用户拒绝了摄像头权限。请到 Safari → 设置 → 网站 → 摄像头 中允许此网站。, NotFoundError: 未找到摄像头设备。请检查设备是否有可用摄像头。, NotReadableError: 摄像头被其他应用占用。请关闭其他使用摄像头的应用后重试。, OverconstrainedError: 无法满足视频约束条件。可能是请求的分辨率不支持。, SecurityError: 安全限制。请确保在 HTTPS 环境下使用。, AbortError: 摄像头启动被中止。, TypeError: getUserMedia 不可用可能处于非安全上下文。 }; const friendly messages[name] || 未知错误: ${name} — ${msg}; log(friendly, err); log(原始错误: ${name}: ${msg}, warn); } // // 4. 简单的绘制循环演示用 // let rafId null; function drawLoop() { if (!mediaStream) return; ctx.clearRect(0, 0, canvas.width, canvas.height); // 绘制中心指示框 const cx canvas.width / 2; const cy canvas.height / 2; const size Math.min(canvas.width, canvas.height) * 0.35; ctx.strokeStyle rgba(53, 232, 160, 0.5); ctx.lineWidth 2; ctx.setLineDash([8, 4]); ctx.strokeRect(cx - size / 2, cy - size / 2, size, size); ctx.setLineDash([]); // 四角标记 const cornerLen 16; ctx.strokeStyle rgba(53, 232, 160, 0.9); ctx.lineWidth 3; [[cx - size/2, cy - size/2, 1, 1], [cx size/2, cy - size/2, -1, 1], [cx - size/2, cy size/2, 1, -1], [cx size/2, cy size/2, -1, -1] ].forEach(([x, y, dx, dy]) { ctx.beginPath(); ctx.moveTo(x, y dy * cornerLen); ctx.lineTo(x, y); ctx.lineTo(x dx * cornerLen, y); ctx.stroke(); }); rafId requestAnimationFrame(drawLoop); } // // 5. 事件绑定 初始化 // document.getElementById(btnStart).addEventListener(click, startCamera); document.getElementById(btnStop).addEventListener(click, stopCamera); runDiagnostics(); // 页面离开清理 window.addEventListener(pagehide, stopCamera); window.addEventListener(beforeunload, stopCamera); /script /body /html