项目上有个需求就是说移动端网页中的照相机要支持连拍功能。

要知道咱们一般网页中通过input标签调用出来的照相机都是拍完后需要进行点击确认操作,然后再重新点击拍照按钮调起照相机如此往复,这样的交互方式对于需要快速拍摄照片的场景来说效率确实太低了。

如果本机摄像头无法进行连拍操作的话只能通过直接调用媒体摄像头的方式看看能否实现,考虑到开源的组件UI和交互方式可能无法满足项目需要且也不太好扩展功能所以觉得自己封装一个。

使用到的技术

  • Navigator.mediaDevices.getUserMedia:获取摄像头数据流,用于显示摄像头画面;
  • canvas:捕捉摄像头画面帧,生成可使用的图片数据;
  • lrz:用于图片压缩;
  • orientation.js:判断手机横竖屏

如何在页面上显示摄像头画面

我们可以照着本机摄像头的UI搭建一个照相机组件,80%的高度用来显示摄像头画面(需要使用video标签来展示摄像头画面),20%用来留给操作栏,操作栏中心可以添加一个圆形的拍照按钮。我使用的框架是Taro,css的代码我就不贴了。

 1import { View } from "@tarojs/components"
 2import styles from "./index.module.scss"
 3
 4const test = ()=>{
 5  return (
 6    <View className={styles.test}>
 7      <video className={styles.video}></video>
 8      <View className={styles.controls}>
 9        <button className={styles.save}></button>
10      </View>
11    </View>
12  )
13}
14
15export default test

接下来就是将摄像头画面投射到video标签中,需要先通过js的api:Navigator.mediaDevices.getUserMedia获取摄像头的数据流,这里我创建一个方法来达到兼容效果。 方法中有3个参数constraints、success、error,后2者分别都是成功与失败的回调,error中我们可以添加一些提示来告诉用户”无法打开摄像头“之类的。success函数会接收成功获取的stream流之后再进行处理。

 1  const getUserMedia = (constraints: any, success: any, error: any) => {
 2    const Navigator: any = navigator
 3    if (Navigator.mediaDevices.getUserMedia) {
 4      
 5      Navigator.mediaDevices.getUserMedia(constraints).then(success).catch(error);
 6    } else if (Navigator.webkitGetUserMedia) {
 7      
 8      Navigator.webkitGetUserMedia(constraints, success, error)
 9    } else if (Navigator.mozGetUserMedia) {
10      
11      Navigator.mozGetUserMedia(constraints, success, error);
12    } else if (Navigator.getUserMedia) {
13      
14      Navigator.getUserMedia(constraints, success, error);
15    }
16  }

先讲一下constraints参数是什么作用,应该传什么值。constraints参数决定了摄像头展示画面的分辨率、前后置摄像头画面、焦距、声音等等。针对项目需要其实我这里只需要分辨率和前后置摄像头,所以我只需要将constraints按照如下配置即可。facingMode.exact决定了展示前置摄像头画面还是后置摄像头画面,”environment“表示前置,”user“表示后置,width和height表示画面分辨率,分辨率越高显示的画面就越清晰。

 1let getConstrants = () => {
 2    return new Promise(async (res) => {
 3      res({
 4        audio: false,
 5        video: {
 6          facingMode: { exact: "environment" },
 7          width: 1920,
 8          height: 1440,
 9        }
10      })
11    });
12  };

通过以上方式我们就可以成功获取stream流,接下来只需要将stream赋值给video标签,video标签就会自动展示摄像头画面了。我看网上也有方法是将video不在页面中显示,只是将video的画面投射到一个canvas中,将canvas显示在页面上,这样的方法逻辑会更加复杂但是实现的效果也没有比video标签直接显示好,如果大家知道为什么要这样处理可以在评论区说一下。

 1import { View } from "@tarojs/components"
 2import styles from "./index.module.scss"
 3import { useEffect, useRef } from "react"
 4import Taro from "@tarojs/taro"
 5
 6const test = () => {
 7
 8  const video: any = useRef(null)
 9
10  useEffect(() => {
11    getUserMedia(getConstrants(), getStream, noStream)
12  }, [])
13
14  
15  const getStream = async (stream: any) => {
16    if ("srcObject" in video.current) {
17      video.current.srcObject = stream
18    } else {
19      video.current.src = window.URL && window.URL.createObjectURL(stream) || stream
20    }
21    video.current.onloadedmetadata = () => {
22      console.log('视频流成功加载');
23      video.current.play();
24    };
25  }
26
27  
28  let getConstrants = () => {
29    return {
30      audio: false,
31      video: {
32        facingMode: { exact: "user" },
33        width: 1920,
34        height: 1440,
35      }
36    }
37  };
38
39  const noStream = () => {
40    Taro.showToast({
41      title: '当前无法展示摄像头画面',
42      icon: 'error'
43    })
44  }
45
46  const getUserMedia = (constraints: any, success: any, error: any) => {
47    const Navigator: any = navigator
48    if (Navigator.mediaDevices.getUserMedia) {
49      
50      Navigator.mediaDevices.getUserMedia(constraints).then(success).catch(error);
51    } else if (Navigator.webkitGetUserMedia) {
52      
53      Navigator.webkitGetUserMedia(constraints, success, error)
54    } else if (Navigator.mozGetUserMedia) {
55      
56      Navigator.mozGetUserMedia(constraints, success, error);
57    } else if (Navigator.getUserMedia) {
58      
59      Navigator.getUserMedia(constraints, success, error);
60    }
61  }
62
63  return (
64    <View className={styles.test}>
65      <video className={styles.video} ref={video}></video>
66      <View className={styles.controls}>
67        <button className={styles.save}></button>
68      </View>
69    </View>
70  )
71}
72
73export default test

如何关闭摄像头画面

关闭摄像头也需要调用video的stream流方法,否则手机端会看到顶部的摄像头状态栏一直在。 可以在底部操作栏中添加一个关闭按钮,通过点击触发关闭方法。

 1  const handleBack = () => {
 2    const videoStream = video.current.srcObject || video.current.src;
 3    if (videoStream) {
 4      videoStream.getVideoTracks().forEach(track => {
 5        track.stop()
 6      })
 7    }
 8  }

截取摄像头画面生成图片

我们需要按照拍照按钮时捕获video中画面,将其绘制在一个canvas上,然后再利用canvas的其他api将其进行处理,我们就能得到一个可使用的图片数据了。

我的思路就是利用canvas的drawImage方法将video的画面绘制到一个指定分辨率的canvas上,然后利用toDataUrl将canvas转化为base64的图片数据,这个base64数据就是我们最后需要的结果,我们可以将这个base64从组件抛出,这样外部就能使用这个数据做其他业务处理。

如下代码中imageURLWidth、imageURLHeight代表了video画面的分辨率,outputCanvas就是用来绘制video画面的canvas。outputCanvas.width和outputCanvas.height就是在指定最后canva的分辨率,这里我固定分辨率为480 X 640,这个分辨率会影响最终图片的清晰度和图片大小。outputContext.drawImage这一步的代码意为从video的左上角开始截取宽高为imageURLWidth和imageURLHeight的画面(其实就是截取整个video画面),绘制到outputCanvas上,且在canvas上显示的画面也是从canvas的左上角开始(将1920 X 1440的画面压缩到一个480 X 640的新框架中)。

compressImgOnlyForTakePhots就是图片压缩方法,这个方法只需要2个参数,一个是base64的图片数据,一个是图片压缩比例。如果生成的canvas分辨率较高那图片大小也会较大,这时候压缩方法会有作用。

这块我可能表述的也不是太清楚,大家可以搜索”js canvas drawImage“的相关知识博客,这样再来直接看我的代码会豁然开朗。

 1  const takePhotos = async () => {
 2    const videoStream = video.current.srcObject || video.current.src;
 3    let imageURLWidth = videoStream.getVideoTracks()[0].getSettings().width;
 4    let imageURLHeight = videoStream.getVideoTracks()[0].getSettings().height;
 5    const outputCanvas = document.createElement('canvas');
 6    const outputContext: any = outputCanvas.getContext('2d');
 7    let dataurl: any
 8    outputCanvas.width = 480;
 9    outputCanvas.height = 640;
10    outputContext.drawImage(video.current, 0, 0, imageURLWidth, imageURLHeight, 0, 0, outputCanvas.width, outputCanvas.height)
11    dataurl = outputCanvas.toDataURL('image/jpeg');
12    dataurl = await compressImgOnlyForTakePhots(dataurl, 0.8);
13    console.log(dataurl);
14  }
15
16  const compressImgOnlyForTakePhots = (dataURL, quality = 0.7) => {
17    return new Promise((resolve) => {
18      lrz(dataURL, { quality })
19        .then(async function (rst) {
20          
21          resolve(rst.base64)
22        })
23        .catch(function (err) {
24          
25          console.log('图片压缩处理失败', err);
26        })
27    })
28  }

我们还可以在组件左下角添加一个图片预览的小窗口,就像许多手机相机一样拍摄照片后左下角会有一块区域显示图片缩略图,点击后会跳转至相册。这里我只实现预览功能,照片拍摄成功后左下角会出现缩略图2s后自动消失,如果短时间内连续拍照新图片会将旧缩略图覆盖。

 1  const timer: any = useRef(null)
 2  const previewImg: any = useRef(null)
 3    ...
 4    dataurl = await compressImgOnlyForTakePhots(dataurl, 0.8);
 5    showPreviewImg(dataurl)
 6    hidePreviewImg()
 7  }
 8  
 9    
10  const showPreviewImg = (dataurl) => {
11    const dom: any = previewImg.current
12    dom.src = dataurl
13    dom.style.display = 'block'
14  }
15
16  
17  const hidePreviewImg = () => {
18    clearTimeout(timer.current);
19    timer.current = setTimeout(() => {
20      const dom: any = previewImg.current;
21      dom.style.display = 'none';
22    }, 2000);
23  }
24  
25  <img className={styles.previewImg} ref={previewImg} />

如何实现摄像头画面的放大和缩小

通常手机照相机通过双指滑动实现摄像头画面的放大缩小功能,项目上也有这个需求主要原因是拍高处物体时原版摄像头的焦距并不清晰。我研究了一下,其实Navigator.mediaDevices.getUserMedia获取到的视频流并没有api能有焦距放大缩小的效果,看到一个摄像头插件说是可以模拟出焦距效果,但是相关的摄像头一整套流畅也需要使用插件,考虑到学习成本和可能存在的其他问题只能另辟蹊径。

后来我想到通过css的transform:scale放大缩小video标签的大小来达到放大缩小的效果,其实这个方法也不能算真的达到了焦距放大缩小的效果,因为放大缩小时摄像头的分辨率是不变的,所以越是放大显示的画面会越模糊,所以这个时候需要初始显示的画面分辨率就要清晰度高一些,我选择的就是1920 X 1440这样一个分辨率,还好最后实现的效果能够满足项目需要。

这块功能主要实现的难点在于摄像头画面方法缩小后你仍然需要截取显示在页面上的画面,不能最后生成的图片包括到了超出页面的范围。我画了一张草图来方便理解。

接下来先把video标签放大缩小的画面效果实现出来,之后再考虑图片生成的逻辑。 这里我使用按钮点击的方式来实现画面的放大缩小,我会在操作栏右侧新增一块区域专门控制画面放大缩小,放大、缩小分别一个按钮,再显示一下当前的放大倍率。

我这里将放大的倍率控制在了1~4,因为足够项目需要。

 1  const [scale, setscale]: any = useState(1)
 2    
 3  const handleAmplify = () => {
 4    if (scale >= 4) {
 5      Taro.showToast({
 6        icon: 'error',
 7        title: '放大到最大倍数了'
 8      })
 9      return
10    }
11    let number = Number((scale + 0.2).toFixed(1))
12    setscale(number)
13    const videoElement: any = video.current
14    videoElement.style.transform = `scale(${number})`;
15  }
16
17  
18  const handleReduce = () => {
19    
20    if (scale <= 1) return
21    const number = Number((scale - 0.2).toFixed(1))
22    setscale(number)
23    const videoElement: any = video.current
24    videoElement.style.transform = `scale(${number})`;
25  }
26
27 <View className={styles.right}>
28    <button className={styles.amplify} onClick={handleAmplify}>放大</button>
29    <View className={styles.scale}>当前倍率:{scale}</View>
30    <button className={styles.reduce} onClick={handleReduce}>缩小</button>
31 </View>
32

个人笔记记录 2021 ~ 2025