网页调用的是用户电脑或手机上的摄像头,不是网络相机的 RTSP 地址。getUserMedia 需要安全上下文:本机 localhost 可以实验,跨机器部署使用 HTTPS。

保存一个拍照页面

保存为 camera.html,在文件目录运行 python -m http.server 8092 --bind 127.0.0.1,然后打开 http://127.0.0.1:8092/camera.html:

<!doctype html>
<html lang="zh-CN">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>摄像头拍照</title>
<body>
<button id="open">打开摄像头</button>
<button id="capture" disabled>拍照</button>
<button id="close">关闭</button>
<p id="status"></p>
<video id="video" autoplay muted playsinline style="max-width:100%"></video>
<canvas id="canvas" hidden></canvas>
<img id="preview" alt="拍照预览" style="max-width:100%">
<a id="download" hidden download="photo.jpg">下载照片</a>
<script>
const video = document.querySelector('#video')
const capture = document.querySelector('#capture')
const status = document.querySelector('#status')
const preview = document.querySelector('#preview')
const download = document.querySelector('#download')
let stream, photoUrl, version = 0, capturing = false
function close() {
version++
stream?.getTracks().forEach(track => track.stop())
stream = undefined
video.srcObject = null
capture.disabled = true
}
document.querySelector('#open').onclick = async () => {
close()
const current = version
try {
if (!navigator.mediaDevices?.getUserMedia) throw new Error('需要 HTTPS 或 localhost')
const next = await navigator.mediaDevices.getUserMedia({ video: true, audio: false })
if (current !== version) {
next.getTracks().forEach(track => track.stop())
return
}
stream = next
video.srcObject = stream
await video.play()
if (current === version) status.textContent = '摄像头已打开,等待画面'
} catch (error) {
if (current !== version) return
close()
status.textContent = `打开失败:${error.name || error.message}`
}
}
video.onloadeddata = () => {
if (stream && video.videoWidth > 0) capture.disabled = false
}
document.querySelector('#close').onclick = () => {
close()
status.textContent = '摄像头已关闭'
}
capture.onclick = async () => {
if (capturing || !stream || !video.videoWidth) return
capturing = true
try {
const canvas = document.querySelector('#canvas')
canvas.width = video.videoWidth
canvas.height = video.videoHeight
canvas.getContext('2d').drawImage(video, 0, 0)
const blob = await new Promise(resolve => canvas.toBlob(resolve, 'image/jpeg', 0.9))
if (!blob) throw new Error('图片编码失败')
if (photoUrl) URL.revokeObjectURL(photoUrl)
photoUrl = URL.createObjectURL(blob)
preview.src = photoUrl
download.href = photoUrl
download.hidden = false
status.textContent = '照片已生成,点击下载'
} catch (error) { status.textContent = error.message }
finally { capturing = false }
}
document.addEventListener('visibilitychange', () => { if (document.hidden) close() })
window.addEventListener('pagehide', () => {
close()
if (photoUrl) URL.revokeObjectURL(photoUrl)
})
</script>
</body>
</html>

videoWidth/videoHeight 是实际帧尺寸,不能用页面 CSS 宽高替代。拍照结果是 Blob,生成临时对象 URL 用于预览,换照片和离开页面时释放。这里的“下载”没有上传服务器,也不能证明用户已完成保存。

打开后再关闭,设备指示灯应熄灭;权限请求晚返回时也会停止那条流。拒绝权限、没有设备或设备被占用时,显示错误并由用户重新操作,不递归无限切换摄像头。

参考:MDN getUserMedia、Canvas toBlob。