如何构建一个简单的摄像头组件
特别声明,本文根据@David East的《HOW TO BUILD A SIMPLE CAMERA COMPONENT》一文所整理。
要构建一个camera
组件,我们首先要了解所需的浏览器API。
- 使用
MediaDevices
API获取相机访问权限 - 使用
video
元素播放MediaStream
- 使用
canvas
元素以blob
或base64
形式拍照
让我们构建一具自定义的camera
元素,这样你就不必再担心把这些代码连接起来。
使用自定义元素构建可跨框架重用的组件
这篇文章并没有指定在哪个框架构建摄像头组件。叶节点(Leaf Node)组件应该是可重用的。自定义元素是一种新的浏览器标准,允许你构建可在大多数JavaScript框架中移植的可重用元素。如果你不熟悉自定义元素(Custom Elements)并不重要。因为接下来的示例都是一些简单的示例,所以使用自定义元素并不复杂。在高级情况下,它会变得复杂,但我们将会避开这些。这是一个简单的例子:
class HelloElement extends HTMLElement {
constructor() {
// 调用构造函数不是必须的。如果你这样做,一定要确认调用了`super()`
super();
}
// 当元素连接到DOM时调用这个函数
connectedCallback() {
// 附上一个shadow,这样任何人都不会弄乱你的样式
const shadow = this.attachShadow({ mode: 'open' });
shadow.textContent = 'Hello world!';
}
}
// 定义标签名,它必须有一个破折号
customElements.define('hello-element', HelloElement);
在HTML中你可以像下面这样调用自定义的元素hello-element
:
<hello-element></hello-element>
你在浏览器运行上面的代码之后,将看到的效果如下图所示:
这是自定义元素的简单用法。就像我说的,它也可以变得更复杂,但我们这里将要构建的是一个摄像头组件,而且是一个简单的摄像头组件,所以会尽量让它保持简单。
摄像头组件需要一个video元素和一个隐藏的canvas元素
让我们从简单的camera
组件开始。
class SimpleCamera extends HTMLElement {
constructor() {
super();
}
connectedCallback() {
const shadow = this.attachShadow({
mode: 'open'
})
this.videoElement = document.createElement('video')
this.canvasElement = document.createElement('canvas')
this.videoElement.setAttribute('playsinline', true)
this.canvasElement.style.display = 'none'
shadow.appendChild(this.videoElement)
shadow.appendChild(this.canvasElement)
}
}
customElements.define('simple-camera', SimpleCamera)
该组件只添加了两个元素:一个是video
元素和一个隐藏的canvas
元素。
在 iOS 10 Safari 中,通过 playsinline
可以让视频内联播放。设置了 playsinline
属性的视频在播放时不会自动全屏,但用户可以点击全屏按钮来手动全屏;没有设置 playsinline
的视频会在播放时自动全屏。无论是否设置 playsinline
属性,退出全屏后视频都会继续播放。
playsinline
属性在 iOS 10 之前需要写成 webkit-playsinline
,它的浏览器厂商前缀在 iOS 10 中被移除。但是目前 iOS 微信还不支持去掉前缀的写法,两个属性最好都加上。
显然,<video>
的 autoplay
必须和 playsinline
属性一起使用。也就是说,只有默认内联播放的视频才有可能自动播放,这一点很容易理解。
然后在HTML中像下面这样调用自定义好的元素:
<simple-camera></simple-camera>
这样就可以为摄像机创建一个元素。也可以开始播放一些视频。
好像啥也没有一样,是不。不急,咱们继续往下。
通过MediaDevices
API授权访问摄像头
使用navigator.mediaDevices.getUserMedia()
方法,授权用户访问摄像头。
navigator.mediaDevices.getUserMedia(constraints).then((mediaStream) => {
})
请注意,getUserMedia()
会返回一个Promise
。如果返回成功,Promise
会解析MediaStream
。此流(Stream)将会用于video
元素。如果Promise
拒绝(rejects
),表示用户未授权访问摄像头。然而!Promise
有可能永远不会解决(resolve
)或拒绝(reject
)。用户可以决定永远不对权限弹出框执行操作。那不是很好玩吗?
浏览器对MediaDevices
的支持很强大,但很奇怪
MediaDevices
API得到浏览器强大的支持。它可以在所有现代浏览器中使用。然而,在IE中没有得到支持,所以你需要对该特性做一个检查。
if (navigator.mediaDevices.getUserMedia === undefined) {
navigator.mediaDevices.getUserMedia(constraints).then((mediaStream) => {
})
}
然而,一些浏览器版本对MediaDevices
的API只有部分支持,有些则需要添加浏览器供应商的前端才能实现。MDN文章中有一个关于设置Polyfills的部分有介绍到这方面的知识。幸运的是,这些Polyfill应该应用在元素之外,所以我们不需要在元素中考虑这个。
为mediaStream的audio和video设置相应的约束
getUserMedia()
方法接受一组约束。这些限制有助于在用户接受权限后配置流。它们具有MediaStreamConstraints
的类型。你可以指定两个主要属性:audio
和video
。
if (navigator.mediaDevices.getUserMedia === undefined) {
navigator.mediaDevices.getUserMedia({
audio: false,
video: {
facingMode: 'user'
}
}).then((mediaStream) => {
})
}
audio
属性是一个简单的布尔值。你要么请求用户的音频,要么不请求。video
属性要复杂得多。视频约束,也称为MediaTrackConstraints
,指定了视频流可能需要的所有内容:echoCancellation
、latency
、 sampleRate
、 sampleSize
、 volume
、 noiseSuppression
、 frameRate
、 aspectRatio
、 facingMode
,当然还有width
和height
。
有很多约束。然而,除非你正开发一个摄像头应用程序,否则你只需要几个。即:height
、width
和facingMode
。
将MediaStream分配给video元素
现在已经配置了MediaStream
,就可以将其分配给video
元素。
open(constraints) {
return navigator.mediaDevices.getUserMedia(constraints)
.then((mediaStream) => {
// 分配MediaStream
this.videoElement.srcObject = mediaStream
// 加载时播放流
this.videoElement.onloadedmetadata = (e) => {
this.videoElement.play()
}
})
}
video
元素有一个srcObject
。它在分配MediaStream
时从设置的摄像头流式输出。上面的代码片段在元素上添加了一个open
方法。自定义元素具有可调用方法。如果用户调用这个open
方法,它将启动视频流。
<script>
(async function() {
const camera = document.querySelector('simple-camera')
await camera.open({
video: {
facingMode: 'user'
}
})
}())
</script>
现在我们可以播放视频,让我们拍照。
使用canvas将照片作为blob拍摄
canvas
元素能够从video
元素中绘制帧。使用此功能,你可以在不可见的canvas
上绘制,然后将图像导出为blob
。
_drawImage() {
const imageWidth = this.videoElement.videoWidth
const imageHeight = this.videoElement.videoHeight
const context = this.canvasElement.getContext('2d')
this.canvasElement.width = imageWidth
this.canvasElement.height = imageHeight
context.drawImage(this.videoElement, 0, 0, imageWidth, imageHeight)
return {
imageHeight,
imageWidth
}
}
这个私有的_drawImage()
方法将不可见的canvas
的height
和width
设置为video
的大小。然后在上下文(context
)中使用drawImage()
方法。提供video
元素的x
、y
位置,width
和height
。这将在不可见的canvas
上绘图,并将相关设置创建为一个blob
。
takeBlobPhoto() {
const {imageHeight, imageWidth} = this._drawImage()
return new Promise((resolve, reject) => {
this.canvasElement.toBlob((blob) => {
resolve({blob, imageHeight, imageWidth})
})
})
}
canvas
元素有一个toBlob()
方法。由于它是异步的,所以你可以将它转换为一个Promise
,这样它就更容易使用。
现在你可以开始控制这个相机了:
<simple-camera></simple-camera>
<button id="btnPhoto">Take Blob</button>
<script>
(async function(){
const camera = document.querySelector('simple-camera')
const btnPhoto = document.querySelector('#btnPhoto')
await camera.open({
video: {
facingMode: 'user'
}
})
btnPhoto.addEventListener('click', async event => {
const photo = await camera.takeBlobPhoto()
})
}())
</script>
当你需要上传一个文件时,blob
是最好的。但是有时候,在image
标签中插入一个base64
编码的字符串会更好。canvas
有相应的解决方案。
使用canvas把拍摄的图片转换为base64
canvas
元素有现代战争toDataURL()
方法。该方法获取canvas
的当前内容,并将其输出成base64
编码的图像。
takeBase64Photo({type, quality} = {type: 'png', quality: 1}) {
const {imageHeight, imageWidth} = this._drawImage()
const base64 = this.canvasElement.toDataURL('image/' + type, quality)
return {base64, imageHeight, imageWidth}
}
takeBase64()
方法调用toDataURL()
方法并返回它的base64
值。注意,你可以指定图像类型和图像质量。
<simple-camera></simple-camera>
<button id="btnBlobPhoto">Take Blob</button>
<button id="btnBase64Photo">Take Base64</button>
<script>
(async function() {
const camera = document.querySelector('simple-camera')
const btnBlobPhoto = document.querySelector('#btnBlobPhoto')
const btnBase64Photo = document.querySelector('#btnBase64Photo')
await camera.open({video: {facingMode: 'user'}})
btnBlobPhoto.addEventListener('click', async event => {
const photo = await camera.takeBlobPhoto()
})
btnBase64Photo.addEventListener('click', async event => {
const photo = camera.takeBase64Photo({type: 'jpeg', quality: 0.8})
})
}())
</script>
把所有代码结合到一起:
<script>
class SimpleCamera extends HTMLElement {
constructor() {
super();
}
connectedCallback() {
const shadow = this.attachShadow({
mode: 'open'
})
this.videoElement = document.createElement('video')
this.canvasElement = document.createElement('canvas')
this.videoElement.setAttribute('playsinline', true)
this.canvasElement.style.display = 'none'
shadow.appendChild(this.videoElement)
shadow.appendChild(this.canvasElement)
}
open(constraints) {
return navigator.mediaDevices.getUserMedia(constraints).then((mediaStream) => {
this.videoElement.srcObject = mediaStream
console.log(mediaStream)
this.videoElement.onloadedmetadata = (e) => {
this.videoElement.play()
}
})
}
_drawImage() {
const imageWidth = this.videoElement.videoWidth
const imageHeight = this.videoElement.videoHeight
const context = this.canvasElement.getContext('2d')
this.canvasElement.width = imageWidth
this.canvasElement.height = imageHeight
context.drawImage(this.videoElement, 0, 0, imageWidth, imageHeight)
return {
imageHeight,
imageWidth
}
}
takeBlobPhoto() {
const {imageHeight, imageWidth} = this._drawImage()
this.canvasElement.style.display="block"
const card = document.createElement('div')
card.classList.add('card')
document.querySelector('.wrapper').appendChild(card)
card.appendChild(this.canvasElement)
return new Promise((resolve, reject) => {
this.canvasElement.toBlob((blob) => {
resolve({blob, imageHeight, imageWidth})
})
})
}
takeBase64Photo({type, quality} = {type: 'png', quality: 1}) {
const {imageHeight, imageWidth} = this._drawImage()
const base64 = this.canvasElement.toDataURL('image/' + type, quality)
this.canvasElement.style.display="block"
const card = document.createElement('div')
card.classList.add('card')
document.querySelector('.wrapper').appendChild(card)
card.appendChild(this.canvasElement)
return {base64, imageHeight, imageWidth}
}
}
customElements.define('simple-camera', SimpleCamera)
</script>
<div class="wrapper">
<div class="card">
<simple-camera></simple-camera>
<div class="active">
<button id="btnBlobPhoto">Take Blob</button>
<button id="btnBase64Photo">Take Base64</button>
</div>
</div>
</div>
<script>
(async function() {
const camera = document.querySelector('simple-camera')
const btnBlobPhoto = document.querySelector('#btnBlobPhoto')
const btnBase64Photo = document.querySelector('#btnBase64Photo')
await camera.open({video: {facingMode: 'user'}})
btnBlobPhoto.addEventListener('click', async event => {
const photo = await camera.takeBlobPhoto()
})
btnBase64Photo.addEventListener('click', async event => {
const photo = camera.takeBase64Photo({type: 'jpeg', quality: 0.8})
})
}())
</script>
Demo效果如下:
移植到你最喜欢的框架中
现代JavaScript框架能够使用自定义元素。这使得自定义元素成为构建通用组件的一个极有吸引力的选择。如果你的公司使用多个框架来开发应用程序,你可以轻松地把该组件移植到你的框架中。无处不在的自定义元素显示了每个框架与自定义元素的兼容性。
扩展阅读
- Media Capture and Streams
- HOW TO BUILD A SIMPLE CAMERA COMPONENT
- JavaScript 使用
mediaDevices
API 选择摄像头 - Capturing Audio & Video in HTML5
- An Intro to WebRTC and Accessing a User’s Media Devices
- Selecting a specific camera with the MediaDevices API
- 采集用户的图像
- JS控制设备摄像头初探
getUserMedia
API的两个使用案例- 如何使用Web录制视频
- Using custom elements
- The Case for Custom Elements: Part 1
- The Case for Custom Elements: Part 2
- Vue as Web Components: Custom Elements
- Create custom, distributable web components with VueJSZoom Kobe XII ZK12