为什么要封装一个H5照相机组件
项目上有个需求就是说移动端网页中的照相机要支持连拍功能。
要知道咱们一般网页中通过input标签调用出来的照相机都是拍完后需要进行点击确认操作,然后再重新点击拍照按钮调起照相机如此往复,这样的交互方式对于需要快速拍摄的场景来说效率确实太低了。
如果本机摄像头无法进行连拍操作的话只能通过直接调用媒体摄像头的方式看看能否实现,考虑到开源的组件可能无法完全满足项目需要且也不太好扩展功能所以决定自己封装一个。
使用到的技术
Navigator.mediaDevices.getUserMedia:获取摄像头数据流,用于显示摄像头画面;
canvas:捕捉摄像头画面帧,生成可使用的图片数据;
如何在页面上显示摄像头画面
我参照本机摄像头的UI搭建一个照相机组件框架,80%的高度用来显示摄像头画面(需要使用video标签来展示摄像头画面),20%用来留给操作栏,操作栏中心可以添加一个圆形的拍照按钮。
import { View } from "@tarojs/components" import styles from "./index.module.scss" const test = () => { return ( <View className ={styles.test} > <video className ={styles.video} >video > <View className ={styles.controls} > <button className ={styles.save} >button > View > View > ) }export default test
image.png
接下来将摄像头画面投射到video标签中,这一效果通过js的api:Navigator.mediaDevices.getUserMedia先获取摄像头的数据流。这里我创建一个方法来获取数据流。
方法中有3个参数constraints、success、error。success函数会接收成功获取的stream流之后再进行处理,error中我们可以添加一些提示来告诉用户”无法打开摄像头“之类的。
const getUserMedia = (constraints: any, success: any, error: any ) => { const Navigator: any = navigator if (Navigator.mediaDevices.getUserMedia) { //最新的标准API Navigator.mediaDevices.getUserMedia(constraints).then(success).catch(error); } else if (Navigator.webkitGetUserMedia) { //webkit核心浏览器 Navigator.webkitGetUserMedia(constraints, success, error) } else if (Navigator.mozGetUserMedia) { //firfox浏览器 Navigator.mozGetUserMedia(constraints, success, error); } else if (Navigator.getUserMedia) { //旧版API Navigator.getUserMedia(constraints, success, error); } }
constraints参数决定了摄像头展示画面的分辨率、前后置摄像头画面、焦距、声音等等。针对项目需要这里只设置分辨率和前后置摄像头参数,constraints按照如下配置即可。facingMode.exact决定了画面由前置摄像头取还是后置摄像头,”environment“表示后置,”user“表示前置,width和height表示画面分辨率,分辨率越高显示的画面就越清晰。
let getConstrants = () => { return new Promise (async (res) => { res({ audio : false , video : { facingMode : { exact : "environment" }, width : 1920 , height : 1440 , } }) }); };
通过以上方式我们就可以成功获取stream流,接下来将stream赋值到video的srcObject属性上,video就会自动展示摄像头画面了。我看网上也有方法是将video不在页面中显示,只是将video的画面投射到一个canvas中,将canvas显示在页面上,这样的方法逻辑会更加复杂但是实现的效果也没有比video标签直接显示好,如果大家知道为什么要这样处理可以在评论区说一下。
import { View } from "@tarojs/components" import styles from "./index.module.scss" import { useEffect, useRef } from "react" import Taro from "@tarojs/taro" const test = () => { const video: any = useRef(null ) useEffect(() => { getUserMedia(getConstrants(), getStream, noStream) }, []) //将摄像机影像投到video上 const getStream = async (stream: any) => { if ("srcObject" in video.current) { video.current.srcObject = stream } else { video.current.src = window .URL && window .URL.createObjectURL(stream) || stream } video.current.onloadedmetadata = () => { console .log('视频流成功加载' ); video.current.play(); }; } //获取摄像头媒体 let getConstrants = () => { return { audio : false , video : { facingMode : { exact : "user" }, width : 1920 , height : 1440 , } } }; const noStream = () => { Taro.showToast({ title : '当前无法展示摄像头画面' , icon : 'error' }) } const getUserMedia = (constraints: any, success: any, error: any ) => { const Navigator: any = navigator if (Navigator.mediaDevices.getUserMedia) { //最新的标准API Navigator.mediaDevices.getUserMedia(constraints).then(success).catch(error); } else if (Navigator.webkitGetUserMedia) { //webkit核心浏览器 Navigator.webkitGetUserMedia(constraints, success, error) } else if (Navigator.mozGetUserMedia) { //firfox浏览器 Navigator.mozGetUserMedia(constraints, success, error); } else
if (Navigator.getUserMedia) { //旧版API Navigator.getUserMedia(constraints, success, error); } } return ( <View className ={styles.test} > <video className ={styles.video} ref ={video} >video > <View className ={styles.controls} > <button className ={styles.save} >button > View > View > ) }export default test
如何关闭摄像头画面
关闭摄像头需要调用video的stream流方法而不是简单卸载组件,否则手机端会看到顶部的摄像头状态栏一直在。UI上可以在底部操作栏中添加一个关闭按钮,通过点击触发关闭方法。
const handleBack = () => { const videoStream = video.current.srcObject || video.current.src; if (videoStream) { videoStream.getVideoTracks().forEach(track => { track.stop() }) } }
截取摄像头画面生成图片
我的思路就是利用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的图片数据,一个是图片压缩比例。
这块我可能表述的也不是太清楚,大家可以搜索”js canvas drawImage“的相关知识博客,这样再来直接看我的代码会更容易理解。
const takePhotos = async () => { const videoStream = video.current.srcObject || video.current.src; let imageURLWidth = videoStream.getVideoTracks()[0 ].getSettings().width; let imageURLHeight = videoStream.getVideoTracks()[0 ].getSettings().height; const outputCanvas = document .createElement('canvas' ); const outputContext: any = outputCanvas.getContext('2d' ); let dataurl: any outputCanvas.width = 480 ; outputCanvas.height = 640 ; outputContext.drawImage(video.current, 0 , 0 , imageURLWidth, imageURLHeight, 0 , 0 , outputCanvas.width, outputCanvas.height) dataurl = outputCanvas.toDataURL('image/jpeg' ); dataurl = await compressImgOnlyForTakePhots(dataurl, 0.8 ); console .log(dataurl); } const compressImgOnlyForTakePhots = (dataURL, quality = 0.7 ) => { return new Promise ((resolve ) => { lrz(dataURL, { quality }) .then(async function (rst ) { // 处理成功会执行 resolve(rst.base64) }) .catch(function (err ) { // 处理失败会执行 console .log('图片压缩处理失败' , err); }) }) }
拍摄图片反馈,预览效果
拍摄后需要给用户提供反馈是否拍摄成功,可以在组件左下角添加一个图片预览的小窗口,就像许多手机相机一样拍摄照片后左下角会有一块区域显示图片缩略图,点击后会跳转至相册。这里只实现预览功能,照片拍摄成功后左下角会出现缩略图2s后自动消失,如果短时间内连续拍照新图片会将旧缩略图覆盖。
const timer: any = useRef(null ) const previewImg: any = useRef(null ) ... dataurl = await compressImgOnlyForTakePhots(dataurl, 0.8 ); showPreviewImg(dataurl) hidePreviewImg() } //展示预览图片 const showPreviewImg = (dataurl ) => { const dom: any = previewImg.current dom.src = dataurl dom.style.display = 'block' } //隐藏预览图片 const hidePreviewImg = () => { clearTimeout(timer.current); timer.current = setTimeout(() => { const dom: any = previewImg.current; dom.style.display = 'none' ; }, 2000 ); }
如何实现摄像头画面的放大和缩小
通常手机照相机通过双指滑动实现摄像头画面的放大缩小功能。我研究了一下,其实Navigator.mediaDevices.getUserMedia获取到的视频流并没有api能有焦距放大缩小的效果,看到一个摄像头插件说是可以模拟出焦距效果,但是相关的摄像头一整套组件也需要使用插件,考虑到学习成本和可能存在其他问题只能另辟蹊径。
后来我想到通过css的transform:scale放大缩小video标签的大小来达到放大缩小的效果,其实这个方法也不能算真的达到了焦距放大缩小的效果,因为这样的话放大缩小时摄像头的分辨率是不变的,所以越是放大显示的画面会越模糊,这个时候需要初始显示的画面分辨率就要清晰度高一些,我选择的就是1920 X 1440这样一个分辨率,还好最后实现的效果能够满足项目需要。
这块功能主要实现的难点在于摄像头画面方法变化后你仍然需要截取可视区上的画面,不能最后生成的图片包括到了超出页面的范围。我画了一张草图来方便理解。
接下来先把video标签放大缩小的画面效果实现出来,之后再考虑图片生成的逻辑。这里我使用按钮点击的方式来实现画面的放大缩小,我会在操作栏右侧新增一块区域专门控制画面放大缩小,放大、缩小分别一个按钮,再显示一下当前的放大倍率。
我这里将放大的倍率控制在了1~4,因为足够项目需要。
const [scale, setscale]: any = useState(1 ) //放大 const handleAmplify = () => { if (scale >= 4 ) { Taro.showToast({ icon : 'error' , title : '放大到最大倍数了' }) return } let number = Number ((scale + 0.2 ).toFixed(1 )) setscale(number) const videoElement: any = video.current videoElement.style.transform = `scale(${number} )` ; } //缩小 const handleReduce = () => { //如果scale<1则无法再缩小 if (scale <= 1 ) return const number = Number ((scale - 0.2 ).toFixed(1 )) setscale(number) const videoElement: any = video.current videoElement.style.transform = `scale(${number} )` ; } <button className ={styles.amplify} onClick ={handleAmplify} > 放大button > <View className ={styles.scale} > 当前倍率:{scale}View > <button className ={styles.reduce}
onClick ={handleReduce} > 缩小button > </View>
image.png
接下来就是考虑如何将放大后的video画面在屏幕中的可视区域截取到canvas上,这里我也画一张草图来方便理解。
image.png
图片中外部的大红框就是放大一定倍数后的video标签,内部的红框就是我们真正需要截取的画面,我在图中标明了4个参数分别是XStart,yStart(中间红框的左上角位于位于外部红框的x,y轴位置),maxWidth,maxHeight(中间红框的最终分辨率),这里我们只需要这四个参数值就能得到最后的图片了。现在我们已知外部红框的分辨率为imageURLWidth、imageURLHeight,被放大的倍数为scale,那要求上面4个未知参数就简单了。
const takePhotos = async () => { const videoStream = video.current.srcObject || video.current.src; let imageURLWidth = videoStream.getVideoTracks()[0 ].getSettings().width; let imageURLHeight = videoStream.getVideoTracks()[0 ].getSettings().height; //裁剪出的图片的x,y轴坐标 let xStart, yStart xStart = imageURLWidth / 2 - 0.5 * (imageURLWidth / scale) yStart = imageURLHeight / 2 - 0.5 * (imageURLHeight / scale) // 最终要裁剪到的尺寸 let maxWidth = imageURLWidth / scale; let maxHeight = imageURLHeight / scale; const outputCanvas = document .createElement('canvas' ); const outputContext: any = outputCanvas.getContext('2d' ); let dataurl: any outputCanvas.width = 480 ; outputCanvas.height = 640 ; outputContext.drawImage(video.current, xStart, yStart, maxWidth, maxHeight, 0 , 0 , outputCanvas.width, outputCanvas.height) dataurl = outputCanvas.toDataURL('image/jpeg' ); dataurl = await compressImgOnlyForTakePhots(dataurl, 0.8 ); showPreviewImg(dataurl) hidePreviewImg() }
这里了解一下canvas drawImage各个参数的含义会更好理解,要注意我这里的计算方式是针对移动端的效果,在真机上的效果能够完全符合放大缩小的需求,如果代码在PC上运行,截取照片的效果会有所差异。
如何打开手电筒
打开手电筒也非常简单只需要一个方法就能够控制,需要注意的是想要手电筒正常启动需要采用后置摄像头画面(需要摄像头参数facingMode.exact = "environment"),大部分手机的前置摄像头都没有手电筒功能。
const [open, setopen]: any = useState(false ) const handleFlashlight = () => { const videoStream = video.current.srcObject || video.current.src; const track = videoStream.getVideoTracks()[0 ] track.applyConstraints({ advanced : [{ torch : !open }] }) setopen(!open) } {open ? '关闭' : '打开' }手电筒</button>
image.png
横竖屏拍照判断
咱们手机本地照相机进行横屏拍照的时候都会进行判断如果横屏拍摄出来的照片都会进行方向矫正。当前我们组件还不支持如果横屏拍摄也没有矫正效果,最后照片拍出来就是旋转过90°的样子。现在就要来实现横屏拍摄矫正的效果。
第一个需要实现的功能就是判断当前手机是横屏状态还是竖屏状态,我在网上查了一下方案分为2种,一种是网页本身会根据手机横竖屏效果产生UI变化,就像咱们看视频一样手机一横视频就是全屏状态了,但是很可惜我项目上的网页压根没有考虑横屏效果,如果横屏状态UI效果直接惨不忍睹,所以这个方案直接pass。另一种方法就是根据手机自身的陀螺仪判断手机横竖屏状态,这个方案也有难点,首先陀螺仪并没有api可以直接告诉你手机是否处于横竖屏,陀螺仪只会以手机为中心分出x,y,z轴告诉你手机相对于3根轴线的偏移角度,还有就是不同型号手机陀螺仪输出的角度也有偏差,比如2个不同型号手机处于相同的偏移角度但是陀螺仪api输出的偏移值是不同的。已知难点后我便在github上搜索有无成熟方案可以解决这些问题,于是我找到了orientation.js,这个js包提高了陀螺仪的兼容性,将不同手机的偏移误差减小,我试验了一下确实横竖屏判断的准确性提高了。但这个包也是只抛出x,y,z轴的偏移角度,横竖屏的判断还需自己处理。不过有了方案就可以动手做起来了。
代码中我使用TransverseOrVertical来记录手机当前处于横屏还是竖屏,按着orientation.js提供的api来监听手机偏移角度的变化,并且在合适位置取消监听避免性能开销。
let TransverseOrVertical = false //横屏或竖屏 let readings: any = [];const sampleSize = 5 ; // 采样大小 const horizontalThreshold = 20 ; // 横屏阈值 const verticalThreshold = 45 ; // 竖屏阈值 useEffect(() => { var ori = new Orientation({ initForwardSlant : 45 , // 可选,初始向前倾斜度 onChange : deviceorientationHandle }) ori.init() return (( ) => { window .removeEventListener('deviceorientation' , deviceorientationHandle) ori.destory() }) }, []) const deviceorientationHandle = throttle((event: any ) => { readings.push(event); if (readings.length >= sampleSize) { // 计算平均值 const average = readings.reduce((acc, cur ) => { return { alpha : acc.alpha + cur.alpha / sampleSize, beta : acc.beta + cur.beta / sampleSize, gamma : acc.gamma + cur.gamma / sampleSize }; }, { alpha : 0 , beta : 0 , gamma : 0 }); const beta = average.beta; // 绕x轴旋转的平均值 const gamma = average.gamma; // 绕y轴旋转的平均值 // 判断设备是否接近水平放置 const isNearlyHorizontal = beta > -horizontalThreshold && beta // 判断设备是否接近垂直放置 const isNearlyVertical = Math .abs(gamma) if (isNearlyHorizontal && isNearlyVertical) { // 当设备接近水平且gamma值接近0时,根据alpha值判断横竖屏 if (average.alpha >= -45 && average.alpha <= 45 || average.alpha >= 135 && average.alpha <= 225 ) { TransverseOrVertical = false ; } else { TransverseOrVertical = true ; } } else { // 当设备不是接近水平时,根据beta和gamma值判断横竖屏 if (gamma >= -verticalThreshold && gamma <= verticalThreshold) { TransverseOrVertical = false ; } else { TransverseOrVertical = true ; } } // 清空读数数组 readings = []; } }, 100 )
这里可以在deviceorientationHandle方法最后打印出TransverseOrVertical的值,在调试器中查看横竖屏的变化。
image.png
真机测试了一下准确度还是不错的。这种判断方法还有一个缺点就是无法判断手机是顺时针90°横过来的还是逆时针90°横过来的。
横竖屏判断好后接下来就是对横屏后的图像进行处理,其实就是对横屏照片进行90°旋转即可,正常照片不做处理。
if (TransverseOrVertical) { outputCanvas.width = 640 ; outputCanvas.height = 480 ; outputContext.save(); outputContext.translate(outputCanvas.width / 2 , outputCanvas.height / 2 ); outputContext.rotate(-Math .PI / 2 ); outputContext.drawImage(video.current, xStart, yStart, maxWidth, maxHeight, -outputCanvas.height / 2 , -outputCanvas.width / 2 , outputCanvas.height, outputCanvas.width); } else { outputCanvas.width = 480 ; outputCanvas.height = 640 ; outputContext.drawImage(video.current, xStart, yStart, maxWidth, maxHeight, 0 , 0 , outputCanvas.width, outputCanvas.height) }
完整代码
import { View } from "@tarojs/components" import styles from "./index.module.scss" import { useEffect, useRef, useState } from "react" import lrz from 'lrz' import Taro from "@tarojs/taro" import { loadScript, throttle } from "@/src/network/utils" import Orientation from 'orientation.js' let TransverseOrVertical = false //横屏或竖屏 let readings: any = [];const sampleSize = 5 ; // 采样大小 const horizontalThreshold = 20 ; // 横屏阈值 const verticalThreshold = 45 ; // 竖屏阈值 const test = () => { const video: any = useRef(null ) const timer: any = useRef(null ) const previewImg: any = useRef(null ) const [scale, setscale]: any = useState(1 ) const [open, setopen]: any = useState(false ) useEffect(() => { getUserMedia(getConstrants(), getStream, noStream) loadScript("https://cdn.jsdelivr.net/npm/vconsole@latest/dist/vconsole.min.js" ) .then(() => { new window .VConsole(); }) .catch((error ) => { console .log("Error:" , error); }); var ori = new Orientation({ initForwardSlant : 45 , // 可选,初始向前倾斜度 onChange : deviceorientationHandle }) ori.init() return (( ) => { window .removeEventListener('deviceorientation' , deviceorientationHandle) ori.destory() }) }, []) const deviceorientationHandle = throttle((event: any ) => { readings.push(event); if (readings.length >= sampleSize) { // 计算平均值 const average = readings.reduce((acc, cur ) => { return { alpha : acc.alpha + cur.alpha / sampleSize, beta : acc.beta + cur.beta / sampleSize, gamma : acc.gamma + cur.gamma / sampleSize }; }, { alpha : 0 , beta : 0 , gamma : 0 }); const beta = average.beta; // 绕x轴旋转的平均值 const gamma = average.gamma; // 绕y轴旋转的平均值 // 判断设备是否接近水平放置 const isNearlyHorizontal = beta > -horizontalThreshold && beta // 判断设备是否接近垂直放置 const isNearlyVertical = Math .abs(gamma) if (isNearlyHorizontal && isNearlyVertical) { // 当设备接近水平且gamma值接近0时,根据alpha值判断横竖屏 if (average.alpha >= -45 && average.alpha <= 45 || average.alpha >= 135 && average.alpha <= 225 ) { TransverseOrVertical = false ; } else { TransverseOrVertical = true ; } } else { // 当设备不是接近水平时,根据beta和gamma值判断横竖屏 if (gamma >= -verticalThreshold && gamma <= verticalThreshold) { TransverseOrVertical = false ; } else { TransverseOrVertical = true ; } } console .log(TransverseOrVertical?'横屏'