前端调取摄像头并实现拍照功能

7011次阅读 545人点赞 作者: WuBin 发布时间: 2023-12-06 14:58:53
扫码到手机查看

window.navigator

最近在开发一个过年贺卡的webAPP,使用手机调取摄像头完成拍摄功能。我简单整理了下实现思路,做一个分享。

首先要注意的是,摄像头和麦克风权限只在https和localhost下允许访问,不是本地mediaDevices直接没有

你想调取手机的摄像头,首先你得先检验当前设备是否有摄像设备,window身上自带了一个navigator属性,这个对象有一个叫做mediaDevices的属性是我们即将用到的。

于是我们就可以先设计一个叫做checkCamera的函数,用来在页面刚开始加载的时候执行。

我们到这一步只是需要判断当前设备是否有摄像头,我们先调取enumerateDevices函数来查看当前媒体设备是否存在。它的返回值是一个promise类型。

其中,如果查询正确的话,enumerateDevices会打印得到如下(本机的各种媒体设备):

checkCamera() {
    return new Promise((resolve) => {
        // 摄像头和麦克风权限只在https和localhost下允许访问,不是本地mediaDevices直接没有
        const navigator = window.navigator.mediaDevices;
        if (!navigator) {
            resolve(false);
        } else {
            //  enumerateDevices 函数来查看当前媒体设备是否存在。它的返回值是一个 promise 类型
            navigator.enumerateDevices()
                .then((devices) => {
                    resolve(Array.isArray(devices) && devices.length > 0);
                })
                .catch(() => {
                    resolve(false);
                });
        }
    });
}

上图可以看出,我的电脑有两个音频设备和一个视频设备,那么我们就可以放下进行下一步了。

获取摄像头

接下来就需要用到函数,navigator.getUserMedia。这个函数接收一个对象作为参数,这个对象可以预设一些值,来作为我们请求摄像头的一些参数。

这里我们的重点是facingMode这个属性,因为我们拍照一般都是后置摄像头。当然我们在电脑上测试的时候还是用前置摄像头,也就是user。

const stream = await navigator.getUserMedia({
    // 不需要音频 所以设置为false
    audio: false,
    video: {
        width: 300,
        height: 300,
        // 强制后置摄像头
        facingMode: {
            // user 前置摄像头
            // exact: "environment" 后置摄像头
            exact: 'user'
        }
    }
});

当你执行了这个函数以后,你会看到浏览器有如下提示:

当出现这个提示的时候,证明已经调用成功了。

到这一步,这个函数只是返回了一个媒体流信息给你,你可以这样简单理解刚刚我们干了什么,首先浏览器向手机申请我想用一下摄像头可以吗?在得到了你本人的确认以后,手机将摄像头的数据线递给了浏览器,:“诺,给你。”,浏览器现在仅仅拿到了一根数据线,然而浏览器不知道需要将这个摄像头渲染到哪里,它不可能自动帮你接上这根线,你需要自己找地方接上这根数据线。所以这里我们需要请到我们的原生Video标签。

<video ref="cameraVideoRef"
        x-webkit-airplay="true"
        x5-playsinline="true"
        playsinline="true"
        webkit-playsinline="true"
        x5-video-player-type="h5"
        x5-video-player-fullscreen="true"
        x5-video-orientation="portraint"
></video>
这里的关键点在于将流数据赋值给video标签的srcObject属性。就好像你拿到了数据线,插到了显示器上。(tips: 这里需要特别注意,不是 video.src 而是 video.srcObject 请务必注意)

这一步,我们也要考虑,如果用户点击“取消”的话,我们应该怎么办?

所以,我在具体项目种,使用的try-catch:

try {
    const navigator = window.navigator.mediaDevices;
    // 获取摄像流数据
    const stream = await navigator.getUserMedia({
        // 不需要音频 所以设置为false
        audio: false,
        video: {
            width: 500,
            height: 500,
            // 强制后置摄像头
            facingMode: {
                // user 前置摄像头
                // exact: "environment" 后置摄像头
                exact: user
            }
        }
    });
    if (!this.$refs.cameraVideoRef) {
        return;
    }
    this.$refs.cameraVideoRef.srcObject = stream;
    this.$refs.cameraVideoRef.play();
} catch (e) {
    // 如果在用户授权界面,点击-取消
    window.alert('请您“同意”,以便我们开启您的摄像头权限');
    
    this.closeCamera();
}

截图

原理

这里你需要知道一个前提,虽然我们现在看到的视频是连贯的,但其实在浏览器渲染的时候,它其实是一帧一帧渲染的。就像宫崎骏有些动漫一样,是一张一张手写画。

让我们打开 Performance 标签卡,记录一下打开掘金首页的过程,可以看到浏览器的整个渲染过程其实也是一帧一帧拼接到一起,才完成了整个页面的渲染。

知道了这个前提,那么举一反三,我们就可以明白,虽然我们现在已经打开了摄像头,看到的视频好像是在连贯展示,但其实它也是一帧一帧拼到一起的。那现在我们要做的事情就非常明了,当我按下拍照按钮的时候,想办法将 video 标签当前的画面保存下来。在这个场景,我们需要用到canvas的一些能力。

实现

首先创建一个空白的canvas元素,元素的宽高设置为和video标签一致。

const canvas = document.createElement("canvas");
const videoEl = this.$refs.cameraVideoRef;
canvas.width = videoEl.videoWidth;
canvas.height = videoEl.videoHeight;

接下来是重点: 我们需要用到 canvas 的 getContext 方法,先别着急头晕,这里你只需要知道,它接受一个字符串 "2d" 作为参数就行了,它会把这个画布的上下文返回给你。

在这个ctx对象身上,我们只需要用到一个drawImage方法即可,不需要关心其它属性。

http://doc.wubin.work/canvas-api/drawImage.html

感觉参数有点多?没关系,我们再精简一下,我们只需要考虑第二个用法,也就是5参数的写法。(sx,sy 是做裁切用到的,本文用不到,感兴趣可以自行了解。)

context.drawImage(image, dx, dy, dWidth, dHeight);

这里先简单解释一下dxdy是什么意思。在 canvas 里也存在一个看不见的坐标系,起点也是左上角。设想你想在一个HTMLbody元素里写一个距离左边距离100px距离顶部100px的画面,是不是得写margin-left:100px margin-top:100px这样的代码?没错,这里的dydx也是同样的道理。

我们再看 dwidth,和 dheight,从这个名字你就能才出来,肯定和我们将要在画笔里画画的元素的宽度和高度有关,是的,你猜的没错,它就好像你设置一个 div 元素的高度和宽度一样,代表着你将在画布上画的截图的宽高属性。

现在只剩下第一个参数还没解释,这里直接说答案,我们可以直接将 video 标签填进去,ctx 会自动将当前 video 标签的这一帧画面填写进去。现在拍摄按钮的代码应该是这个样子。

const canvas = document.createElement("canvas");
const videoEl = this.$refs.cameraVideoRef;
canvas.width = videoEl.videoWidth;
canvas.height = videoEl.videoHeight;
//拿到 canvas 上下文对象
const ctx = canvas.getContext("2d");
ctx.drawImage(videoEl, 0, 0, canvas.width, canvas.height);
//将 canvas 投到页面上
document.body.appendChild(canvas);

遇到的问题

如何关闭摄像头

方法有很多 

  1. 设置video的srcObject=null 
  2. 直接从dom移除 video 标签
  3. video 设置 display:none

前置摄像头时拍摄画面和人是相反的

getUserMedia使用前置拍摄时,会发现画面是想法的镜像。这里我们分为两步解决,1是修改video渲染画面的镜像;2修改截图后的镜像。

1、修改video渲染画面的镜像

修改这个比较简单,直接把video标签transform: rotateY(180deg);旋转,这样就可以了。

/* 前置摄像头 图像会被反转,使用样式进行纠正 */
video.rotate180{
   transform: rotateY(180deg);
}

但是仅仅翻转镜头还不够,我们发现翻转摄像画面后,截图依然还是“翻转”的,所以,我们需要再对截图的画面也进行翻转。

2、对canvas进行翻转。

这里可以参考我之前的一篇文章:《使用canvas实现对图片的翻转》https://www.wubin.work/blog/articles/450

这里我们在截图的时候执行一次判断:

const canvas = document.createElement("canvas");
const videoEl = this.$refs.cameraVideoRef;
canvas.width = videoEl.videoWidth;
canvas.height = videoEl.videoHeight;

const ctx = canvas.getContext("2d");

// 如果是前置摄像头 那么就需要对canvas进行翻转
if (this.facingMode == 'user') {
    ctx.scale(-1, 1);
    ctx.translate(-videoEl.videoWidth, 0);
}

ctx.drawImage(videoEl, 0, 0, canvas.width, canvas.height);
const base64 = canvas.toDataURL("image/png", 1);
console.log(base64)

拍照组件完整代码

<template>
    <transition name="photoAni"
                @after-enter="afterEnter"
    >
        <div class="take-photograph" v-show="showCamera">
            <headbar text="拍摄照片"></headbar>
            <div class="video-wrapper" :style="videoWrapperStyle">
                    <video ref="cameraVideoRef"
                           x-webkit-airplay="true"
                           x5-playsinline="true"
                           playsinline="true"
                           webkit-playsinline="true"
                           x5-video-player-type="h5"
                           x5-video-player-fullscreen="true"
                           x5-video-orientation="portraint"
                           :class="videoCls"
                    ></video>
            </div>

            <div class="take-controls">
                <div class="take-btn-wrapper">
                    <div class="take-btn" @click="takePicture"></div>
                </div>
                <div class="close">
                    <button type="button" @click="closeCamera">关闭</button>
                </div>
                <div class="turn-reversal" @click="toggleDirection">
                    <i class="icon-reversal"></i>
                </div>
            </div>
        </div>
    </transition>

</template>

<script type="text/ecmascript-6">
    import Headbar from '@/components/headbar/headbar';


    // 摄像头要截取的尺寸
    const cropWidth = Math.floor(document.body.clientWidth) - 20 * 2;
    const FACING = {
        // 前摄像头
        front: "user",
        // 后摄像头
        back: "environment"
    };
    const DEBUG = window.setting.debug || false;

    export default {
        name: "take-photograph",
        emits: [ 'finish' ],
        components: {
            Headbar
        },
        data() {
          return {
              showCamera: false,
              // 摄像头朝向,默认后摄像头 调试模式为前摄像头
              facingMode: DEBUG ? FACING.front : FACING.back
          }
        },
        computed: {
            videoWrapperStyle() {
                return `width: ${cropWidth}px;height: ${cropWidth}px`;
            },
            videoCls() {
                return this.facingMode == FACING.front ? 'rotate180' : ''
            }
        },
        methods: {
            async open() {
                const hasCamera = await this.checkCamera();
                if (!hasCamera) {
                    window.alert('抱歉,未检测到您设备上的摄像头');
                    return;
                }
                this.showCamera = true;

            },
            // 切换摄像头方向
            toggleDirection() {
                // 调式模式下不支持切换
                if (DEBUG) {
                    window.alert('当前为调试模式,不支持切换摄像头方向');
                    return;
                }

                if (this.facingMode == FACING.front) {
                    this.facingMode = FACING.back;
                } else {
                    this.facingMode = FACING.front;
                }
                // 切换时先关闭
                this.$refs.cameraVideoRef.srcObject = null;
                this.getMedia();
            },
            checkCamera() {
                return new Promise((resolve) => {
                    // 摄像头和麦克风权限只在https和localhost下允许访问,不是本地mediaDevices直接没有
                    const navigator = window.navigator.mediaDevices;
                    if (!navigator) {
                        resolve(false);
                    } else {
                        //  enumerateDevices 函数来查看当前媒体设备是否存在。它的返回值是一个 promise 类型
                        navigator.enumerateDevices()
                            .then((devices) => {
                                resolve(Array.isArray(devices) && devices.length > 0);
                            })
                            .catch(() => {
                                resolve(false);
                            });
                    }
                });
            },
            afterEnter() {
                this.getMedia();
            },
            async getMedia() {
                const exact = this.facingMode;
                try {
                    const navigator = window.navigator.mediaDevices;
                    // 获取摄像流数据
                    const stream = await navigator.getUserMedia({
                        // 不需要音频 所以设置为false
                        audio: false,
                        video: {
                            width: cropWidth,
                            height: cropWidth,
                            // 强制后置摄像头
                            facingMode: {
                                // user 前置摄像头
                                // exact: "environment" 后置摄像头
                                exact: exact
                            }
                        }
                    });
                    if (!this.$refs.cameraVideoRef) {
                        return;
                    }
                    this.$refs.cameraVideoRef.srcObject = stream;
                    this.$refs.cameraVideoRef.play();
                } catch (e) {
                    // 如果在用户授权界面,点击-取消
                    window.alert('请您“同意”,以便我们开启您的摄像头权限');
                    this.closeCamera();
                }
            },
            closeCamera() {
                this.$refs.cameraVideoRef.srcObject = null;
                this.showCamera = false;
            },
            takePicture() {
                const canvas = document.createElement("canvas");
                const videoEl = this.$refs.cameraVideoRef;
                canvas.width = videoEl.videoWidth;
                canvas.height = videoEl.videoHeight;

                const ctx = canvas.getContext("2d");

                // 如果是前置摄像头 那么就需要对canvas进行翻转
                if (this.facingMode == FACING.front) {
                    ctx.scale(-1, 1);
                    ctx.translate(-videoEl.videoWidth, 0);
                }

                ctx.drawImage(videoEl, 0, 0, canvas.width, canvas.height);
                const base64 = canvas.toDataURL("image/png", 1);

                DEBUG && this.openBase64(base64);

                this.$emit('finish', base64);

                this.closeCamera();
            },
            openBase64(base64) {
                const img = new window.Image();
                img.src = base64;

                const newWin = window.open('');
                newWin.document.body.style.background = '#000';
                newWin.document.body.style.textAlign = 'center';
                newWin.document.body.appendChild(img);
                newWin.document.title = '图片预览';
                newWin.document.close();
            }
        }
    }
</script>

<style lang="less" rel="stylesheet/less" scoped>
    .take-photograph {
        position: fixed;
        top: 0;
        left: 0;
        right: 0;
        bottom: 0;
        background-color: var(--color-lightblack);

        .video-wrapper{
            margin: 20px auto 0;
            border: 1px solid #000;
            background: #4b4b4b url("icon-camera.svg") center center no-repeat;

            /* 前置摄像头 图像会被反转,使用样式进行纠正 */
            video.rotate180{
                transform: rotateY(180deg);
            }
        }

        .take-btn-wrapper{
            display: flex;
            justify-content: center;
        }

        .take-btn{
            background-color: rgba(255,255,255,0.5);
            width: 20vw;
            height: 20vw;
            border-radius: 50%;
            display: flex;
            justify-content: center;
            align-items: center;
            &:before{
                content: "";
                width: 75%;
                height: 75%;
                display: block;
                background-color: #fff;
                border-radius: 50%;
            }
        }

        .take-controls{
            position: absolute;
            bottom: 0;
            left: 0;
            right: 0;
            padding-bottom: 20px;
        }

        .turn-reversal{
            text-align: center;
            width: 20vw;
            height: 20vw;
            line-height: 23vw;
            text-align: center;
            position: absolute;
            right: 0;
            top: 0px;
        }
        .icon-reversal{
            display: inline-block;
            width: 30px;
            height: 30px;
            background: url("icon-reversal .svg") no-repeat center center;
            background-size: cover;
        }

        .close{
            text-align: center;
            padding: 20px 0 0;
            button{
                width: 40%;
                height: 45px;
                background-color: #2f2f2f;
                color: #fff;
                font-size: 14px;
            }
        }
    }

    .photoAni-enter-active,
    .photoAni-leave-active {
        transition: all 0.3s;
    }

    .photoAni-enter-from,
    .photoAni-leave-to {
        transform: translate3d(100%, 0, 0);
        opacity: 0;
    }
</style>

其中headbar就是一个高度为40px的,简单的顶部横条。调用的组件的时候,使用:

<take-photograph ref="takePhotoRef"
                 @finish="takePhotoFinish"
></take-photograph>

this.$refs.takePhotoRef.open();

DEBUG是调试模式,其主要作用就是在pc端开发时,会将生成的base64在新标签打开,方便查看效果。

window.setting = {
          // 调试模式
          debug: true
}

相关资料

点赞 支持一下 觉得不错?客官您就稍微鼓励一下吧!
关键词:navigator
推荐阅读
  • uniapp实现被浏览器唤起的功能

    当用户打开h5链接时候,点击打开app若用户在已经安装过app的情况下直接打开app,若未安装过跳到应用市场下载安装这个功能在实现上主要分为两种场景,从普通浏览器唤醒以及从微信唤醒。

    8326次阅读 530人点赞 发布时间: 2022-12-14 16:34:53 立即查看
  • Vue

    盘点Vue2和Vue3的10种组件通信方式

    Vue中组件通信方式有很多,其中Vue2和Vue3实现起来也会有很多差异;本文将通过选项式API组合式API以及setup三种不同实现方式全面介绍Vue2和Vue3的组件通信方式。

    3448次阅读 254人点赞 发布时间: 2022-08-19 09:40:16 立即查看
  • JS

    几个高级前端常用的API

    推荐4个前端开发中常用的高端API,分别是MutationObserver、IntersectionObserver、getComputedstyle、getBoundingClientRect、requ...

    13420次阅读 867人点赞 发布时间: 2021-11-11 09:39:54 立即查看
  • PHP

    【正则】一些常用的正则表达式总结

    在日常开发中,正则表达式是非常有用的,正则表达式在每个语言中都是可以使用的,他就跟JSON一样,是通用的。了解一些常用的正则表达式,能大大提高你的工作效率。

    12205次阅读 392人点赞 发布时间: 2021-10-09 15:58:58 立即查看
  • 【中文】免费可商用字体下载与考证

    65款免费、可商用、无任何限制中文字体打包下载,这些字体都是经过长期验证,经得住市场考验的,让您规避被无良厂商起诉的风险。

    10562次阅读 846人点赞 发布时间: 2021-07-05 15:28:45 立即查看
  • Vue

    Vue3开发一个v-loading的自定义指令

    在vue3中实现一个自定义的指令,有助于我们简化开发,简化复用,通过一个指令的调用即可实现一些可高度复用的交互。

    14428次阅读 1151人点赞 发布时间: 2021-07-02 15:58:35 立即查看
  • JS

    关于手机上滚动穿透问题的解决

    当页面出现浮层的时候,滑动浮层的内容,正常情况下预期应该是浮层下边的内容不会滚动;然而事实并非如此。在PC上使用css即可解决,但是在手机端,情况就变的比较复杂,就需要禁止触摸事件才可以。

    14338次阅读 1167人点赞 发布时间: 2021-05-31 09:25:50 立即查看
  • Vue

    Vue+html2canvas截图空白的问题

    在使用vue做信网单页专题时,有海报生成的功能,这里推荐2个插件:一个是html2canvas,构造好DOM然后转canvas进行截图;另外使用vue-canvas-poster(这个截止到2021年3月...

    27472次阅读 2156人点赞 发布时间: 2021-03-02 09:04:51 立即查看
  • Vue

    vue-router4过度动画无效解决方案

    在初次使用vue3+vue-router4时候,先后遇到了过度动画transition进入和退出分别无效的情况,搜遍百度没没找到合适解决方法,包括vue-route4有一些API都进行了变化,以前的一些操...

    23571次阅读 1816人点赞 发布时间: 2021-02-23 13:37:20 立即查看
交流 收藏 目录