大漠教你构建简单的摄像头组件

本文转载于  前端时空  

作者简介

常用昵称“大漠”,W3CPlus[1]创始人,目前就职于淘宝。对HTML5CSS3CSS处理器等前端脚本语言有非常深入的认识和丰富的实践经验,尤其专注对CSS3和动画的研究,是国内最早研究和使用CSS3CSS处理器技术的一批人。现在主要在探讨学习JavaScriptReactVue相关技术知识。CSS3CSS处理器和Drupal中国布道者。2014年出版《图解CSS3:核心技术与案例实战[2]》。

特别声明,本文根据@David East[3]的《HOW TO BUILD A SIMPLE CAMERA COMPONENT[4]》一文所整理。

要构建一个camera组件,我们首先要了解所需的浏览器API。

  • 使用`MediaDevices`[5] API获取相机访问权限

  • 使用`video`[6]元素播放`MediaStream`[7]

  • 使用`canvas`[8]元素以blobbase64形式拍照

让我们构建一个自定义的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文章[9]中有一个关于设置Polyfills的部分有介绍到这方面的知识。幸运的是,这些Polyfill应该应用在元素之外,所以我们不需要在元素中考虑这个。

为mediaStream的audio和video设置相应的约束

getUserMedia()方法接受一组约束。这些限制有助于在用户接受权限后配置流。它们具有`MediaStreamConstraints`[10]的类型。你可以指定两个主要属性:audiovideo

if (navigator.mediaDevices.getUserMedia === undefined) {
    navigator.mediaDevices.getUserMedia({
        audio: false,
        video: {
            facingMode: 'user'
        }
    }).then((mediaStream) => {

    })
}

audio属性是一个简单的布尔值。你要么请求用户的音频,要么不请求。video属性要复杂得多。视频约束,也称为`MediaTrackConstraints`[11],指定了视频流可能需要的所有内容:echoCancellationlatencysampleRatesampleSizevolumenoiseSuppressionframeRateaspectRatiofacingMode,当然还有widthheight

有很多约束。然而,除非你正开发一个摄像头应用程序,否则你只需要几个。即:heightwidthfacingMode

将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()方法将不可见的canvasheightwidth设置为video的大小。然后在上下文(context)中使用drawImage()方法。提供video元素的xy位置,widthheight。这将在不可见的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[12]

  • HOW TO BUILD A SIMPLE CAMERA COMPONENT[13]

  • JavaScript 使用 `mediaDevices` API 选择摄像头[14]

  • Capturing Audio & Video in HTML5[15]

  • An Intro to WebRTC and Accessing a User’s Media Devices[16]

  • Selecting a specific camera with the MediaDevices API[17]

  • 采集用户的图像[18]

  • JS控制设备摄像头初探[19]

  • `getUserMedia` API的两个使用案例[20]

  • 如何使用Web录制视频[21]

  • Using custom elements[22]

  • The Case for Custom Elements: Part 1[23]

  • The Case for Custom Elements: Part 2[24]

  • Vue as Web Components: Custom Elements[25]

  • Create custom, distributable web components with VueJS[26]

参考资料

[1]

W3CPlus: https://www.w3cplus.com/

[2]

图解CSS3:核心技术与案例实战: https://www.w3cplus.com/book-comment.html

[3]

@David East: https://twitter.com/_davideast

[4]

HOW TO BUILD A SIMPLE CAMERA COMPONENT: https://frontendnews.io/editions/2018-08-15-simple-camera-component

[5]

MediaDevices: https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices

[6]

video: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/video

[7]

MediaStream: https://developer.mozilla.org/en-US/docs/Web/API/MediaStream

[8]

canvas: https://developer.mozilla.org/en-US/docs/Web/API/HTMLCanvasElement

[9]

MDN文章: https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/getUserMedia

[10]

MediaStreamConstraints: https://developer.mozilla.org/en-US/docs/Web/API/MediaStreamConstraints

[11]

MediaTrackConstraints: https://developer.mozilla.org/en-US/docs/Web/API/MediaTrackConstraints

[12]

Media Capture and Streams: https://w3c.github.io/mediacapture-main/

[13]

HOW TO BUILD A SIMPLE CAMERA COMPONENT: https://frontendnews.io/editions/2018-08-15-simple-camera-component

[14]

JavaScript 使用 mediaDevices API 选择摄像头: https://www.zcfy.cc/article/choosing-cameras-in-javascript-with-the-mediadevices-api

[15]

Capturing Audio & Video in HTML5: https://www.html5rocks.com/zh/tutorials/getusermedia/intro/

[16]

An Intro to WebRTC and Accessing a User’s Media Devices: https://medium.com/@sebastianpatron/an-intro-to-webrtc-and-accessing-a-users-media-devices-76ca2e2edc73

[17]

Selecting a specific camera with the MediaDevices API: https://blogs.bytecode.com.au/glen/2018/02/28/mediadevices-specific-camera.html

[18]

采集用户的图像: https://developers.google.com/web/fundamentals/media/capturing-images/?hl=zh-cn

[19]

JS控制设备摄像头初探: https://denzel.netlify.com/js/camera_in_js_trial.html

[20]

getUserMedia API的两个使用案例: https://segmentfault.com/a/1190000010826909

[21]

如何使用Web录制视频: https://www.toobug.net/article/capture_video_on_web.html

[22]

Using custom elements: https://developer.mozilla.org/en-US/docs/Web/Web_Components/Using_custom_elements

[23]

The Case for Custom Elements: Part 1: https://medium.com/dev-channel/the-case-for-custom-elements-part-1-65d807b4b439

[24]

The Case for Custom Elements: Part 2: https://medium.com/dev-channel/the-case-for-custom-elements-part-2-2efe42ce9133

[25]

Vue as Web Components: Custom Elements: https://medium.com/@hectorlorenzo/vue-as-web-components-custom-elements-91fbb962608a

[26]

Create custom, distributable web components with VueJS: https://marcelpociot.de/blog/2017-12-08-using-custom-vuejs-elements

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值