摄像头画面直接显示在video标签里很简单,但一旦需要叠加水印、做滤镜、识别人脸或者压缩分辨率,就必须让画面经过canvas这道加工厂。整条链路的思路是:先用getUserMedia拿到摄像头流,把流赋给一个隐藏的video元素播放,再用JavaScript把video的每一帧绘制到canvas上,最后调用canvas的captureStream方法把画布重新变回MediaStream,交给WebRTC或者MediaRecorder使用。看似三步走,实际每一步都藏着坑,下面逐一拆解。

获取摄像头流并绑定到video元素
第一步通过navigator.mediaDevices.getUserMedia申请摄像头权限。注意这个API必须在HTTPS或者localhost环境下才能使用,如果页面是http协议打开的,浏览器会直接拒绝并且控制台报undefined的错误提示。请求参数里可以指定理想分辨率,约束条件写ideal而不是exact,可以避免设备不支持该分辨率时直接失败。
const video = document.createElement('video');
video.autoplay = true;
video.muted = true; // 必须静音,否则部分浏览器不会自动播放
video.playsInline = true; // iOS Safari必需
const constraints = {
video: {
width: { ideal: 1280 },
height: { ideal: 720 },
frameRate: { ideal: 30 }
},
audio: false
};
const stream = await navigator.mediaDevices.getUserMedia(constraints);
video.srcObject = stream;
await video.play(); // 确保开始播放后再绘制这里有一个容易忽略的细节:video元素不一定需要显示在页面上,但绝对不能用display:none隐藏,某些浏览器会因此暂停解码。推荐的做法是把宽高设为1像素并移出可视区域,或者直接不追加到DOM。另外muted和playsInline这两个属性在移动端尤其关键,缺了任何一个,iOS上的Safari都会拒绝自动播放,画面就永远停留在第一帧。
把video的每一帧绘制到canvas
canvas只是一个像素容器,不会自己刷新,必须靠代码不断把video的当前帧画上去。最常见的写法是requestAnimationFrame循环,它的刷新节奏与浏览器渲染管线同步,流畅且省电:
const canvas = document.createElement('canvas');
canvas.width = 1280;
canvas.height = 720;
const ctx = canvas.getContext('2d');
function drawFrame() {
if (video.readyState >= 2) {
// 绘制前可以在这里做滤镜、镜像、加水印等处理
ctx.save();
ctx.translate(canvas.width, 0);
ctx.scale(-1, 1); // 水平镜像,自拍场景常用
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
ctx.restore();
}
requestAnimationFrame(drawFrame);
}
requestAnimationFrame(drawFrame);另一种方式是定时器,比如setInterval每33毫秒执行一次drawImage。两者的区别在于:requestAnimationFrame在页面切到后台标签页时会自动暂停,这既是优点也是隐患。如果你的应用需要在后台持续输出画面(例如后台录制),就应该改用setInterval,或者监听visibilitychange事件做降级处理。还有一种更优雅的方案是video.requestVideoFrameCallback,它在视频产生新帧时才回调,避免重复绘制相同的帧,性能更好,不过Firefox对它的支持还不完整,生产环境需要做能力检测。
canvas的宽高建议在初始化时就固定下来,并且和目标输出分辨率一致。如果在绘制过程中修改canvas.width,会导致画布内容清空,captureStream输出的流也会出现短暂黑帧。如果需要缩小分辨率做性能优化,直接在drawImage的参数里指定目标尺寸即可,不需要真的去改canvas本身。
用captureStream把画布转回MediaStream
canvas.captureStream是整条链路的收口。调用它会返回一个实时跟踪画布内容的视频流,画布每次被修改都会产生新帧。方法可以传入一个帧率参数:
const canvasStream = canvas.captureStream(30);
// 如果原始摄像头流带音频,需要把音轨合并进来
const audioTracks = stream.getAudioTracks();
audioTracks.forEach(track => canvasStream.addTrack(track));
// 交给WebRTC
pc.addTrack(canvasStream.getVideoTracks()[0], canvasStream);
// 或者交给MediaRecorder录制
const recorder = new MediaRecorder(canvasStream, {
mimeType: 'video/webm;codecs=vp9'
});参数传30表示输出流固定为每秒30帧,画布更新不足时会产生重复帧补齐;传0则表示只在画布变化时才产生新帧。对于实时通话场景,建议传一个明确的帧率值,让下游编码器拿到稳定的帧节奏,否则某些编码器会因为帧间隔不均匀而出现画面卡顿。需要注意captureStream存在安全性限制:画布如果被跨域内容污染(tainted),调用时会直接抛出SecurityError,这也是为什么加载跨域图片做水印时必须设置crossOrigin属性。
还有一个兼容性方面的进展值得了解:W3C正在推MediaStreamTrackGenerator,配合VideoTrackWriter可以完全绕开canvas,用WebCodecs直接往轨道里写VideoFrame,性能更好。不过目前主流方案仍然是captureStream,Chrome、Firefox、Safari较新版本都已支持,只是Safari对frameRate参数的处理略有差异,建议在Safari上实测确认。
常见问题排查:黑屏、无帧率、流中断
黑屏是最常见的问题,排查顺序建议如下:先确认video.readyState是否达到2以上,代表已有可绘制的帧;再检查drawImage调用是否真的执行了,可以临时在canvas上叠加一段文字验证绘制循环本身没问题;最后检查canvas是否被跨域内容污染。如果摄像头画面在video里正常但canvas全黑,多半是绘制循环还没启动就调用了captureStream,或者video还没play就进入了第一次绘制。
输出流没有画面还有一种情况:MediaRecorder启动后录出来的是0字节文件。这通常是因为captureStream是在canvas首次绘制之前创建的,某些浏览器要求画布至少有过一次有效内容。稳妥的做法是先执行一次drawImage,再调用captureStream,最后start录制。
页面生命周期管理同样重要。用户关闭摄像头时要依次调用stream.getTracks().forEach(track => track.stop())停止源流,同时stop掉canvasStream的轨道,否则摄像头指示灯会一直亮着,移动端还会持续耗电。另外requestAnimationFrame在后台暂停会导致输出流冻结,做后台录制时务必切换到setInterval方案。最后提醒一点,整条链路的延迟大约会叠加一到两帧,对实时性敏感的场景(比如视频会议)要权衡canvas处理的收益和延迟代价,必要时可以降低canvas分辨率来压缩处理耗时。
摄像头CanvasMediaStream修改时间:2026-09-15 21:40:50