首页/文章/移动端web

封装一个H5照相机组件(放大缩小、连拍、预览、手电筒,横竖屏拍摄)

2024-09-04
270310 分钟
...

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

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

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

使用到的技术

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

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

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

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

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

  const getUserMedia = (constraints: any, success: any, error: any) => {
    const Navigator: any = navigator
    if (Navigator.mediaDevices.getUserMedia) {
      
      Navigator.mediaDevices.getUserMedia(constraints).then(success).catch(error);
    } else if (Navigator.webkitGetUserMedia) {
      
      Navigator.webkitGetUserMedia(constraints, success, error)
    } else if (Navigator.mozGetUserMedia) {
      
      Navigator.mozGetUserMedia(constraints, success, error);
    } else if (Navigator.getUserMedia) {
      
      Navigator.getUserMedia(constraints, success, error);
    }
  }

先讲一下constraints参数是什么作用,应该传什么值。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标签,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)
  }, [])

  
  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) {
      
      Navigator.mediaDevices.getUserMedia(constraints).then(success).catch(error);
    } else if (Navigator.webkitGetUserMedia) {
      
      Navigator.webkitGetUserMedia(constraints, success, error)
    } else if (Navigator.mozGetUserMedia) {
      
      Navigator.mozGetUserMedia(constraints, success, error);
    } else if (Navigator.getUserMedia) {
      
      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流方法,否则手机端会看到顶部的摄像头状态栏一直在。 可以在底部操作栏中添加一个关闭按钮,通过点击触发关闭方法。

  const handleBack = () => {
    const videoStream = video.current.srcObject || video.current.src;
    if (videoStream) {
      videoStream.getVideoTracks().forEach(track => {
        track.stop()
      })
    }
  }

截取摄像头画面生成图片

我们需要按照拍照按钮时捕获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“的相关知识博客,这样再来直接看我的代码会豁然开朗。

  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);
  }
  
  <img className={styles.previewImg} ref={previewImg} />

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

通常手机照相机通过双指滑动实现摄像头画面的放大缩小功能,项目上也有这个需求主要原因是拍高处物体时原版摄像头的焦距并不清晰。我研究了一下,其实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 = () => {
    
    if (scale <= 1) return
    const number = Number((scale - 0.2).toFixed(1))
    setscale(number)
    const videoElement: any = video.current
    videoElement.style.transform = `scale(${number})`;
  }

 <View className={styles.right}>
    <button className={styles.amplify} onClick={handleAmplify}>放大</button>
    <View className={styles.scale}>当前倍率:{scale}</View>
    <button className={styles.reduce} onClick={handleReduce}>缩小</button>
 </View>

如果您觉得这篇文章有帮助,请点个赞吧~

分享文章

相关文章

更多文章 →
移动端web2024-09-04
H5 实现拍照选景框效果利用
背景 在实际项目中,遇到了需要唤起手机摄像头拍照的需求,最开始是通过 的方式,可以直接唤起手机相机,但是用户拍照的方向各式各样,导致后续业务处理时,没有达到预期的效果。 基于此,产品同学期望能在用户拍照时给用户一个引导框(也就是平时我们在用第三方证件拍照时的取景框效果)。经过讨论,给出了两种解决方案,一种是通过我们自研,先尝试看一下效果,第二种是使用第三方的 SDK,仅使用他们的拍照功能。 本文档仅涉及第一种,即通过我们自研的方式,实现...
学习
移动端web2024-09-03
H5移动端-解决点击输入框时页面自动放大的问题
问题 在移动端项目,我们常遇到的一个问题:用户点击输入框时,页面会不期望地发生自动缩放,这既打断了用户的输入流程,也影响了整体的用户体验。 我们的目标是优化这一行为,确保用户在点击输入框时,页面能够保持稳定,仅允许用户专注于内容的输入,从而提升产品的可用性和满意度。 解决前后效果对比 方案 针对移动端点击输入框导致页面自动缩放的问题,我们需要根据不同操作系统(主要是安卓与iOS)采取差异化的解决方案。 安卓:通常可以通过在HTML文档头...
学习
移动端web2024-03-12
h5调用手机摄像头踩坑
一般业务也很少接触摄像头,有也是现成的工具库扫个二维码。难得用一次,记录下踩坑。 2.1. 这个就不用多说了,缺点就是没办法自定义界面,它是调用的系统原生相机界面。 2.2. 由于我需要自定义界面,就像下面这样: 所以我选择了这个方案,这个 使用起来其实很简单: 可以看到这个效果。 如果你复制我的代码,在 上肯定能运行,但是你想在手机上试试的时候就会发现很多问题。 3.1. 需要 由于浏览器的安全设置,除了 和 连接,你都没办法获取到...
学习
移动端web2024-03-12
探秘移动端网页调用摄像头的两种方式
简介: PC 端网页调用摄像头的场景想必大家并不陌生,打开一个网址,开启摄像头开始笔试/视频聊天/直播等。而在移动端网页调用摄像头的场景你见得多吗?我想答案应该是不多吧(在下见识浅薄)。 前言小叙 PC 端网页调用摄像头 的场景想必大家并不陌生,打开一个网址,开启摄像头开始笔试/视频聊天/直播等。 而在 移动端网页调用摄像头 的场景你见得多吗?我想答案应该是不多吧(在下见识浅薄)。 H5 相较于native app 一直被诟病的就有 调...
学习
移动端web2023-12-06
PC调试手机微信浏览器
此方案,可以调试一些仅可在微信客户端才能打开的网页,以及一些需要微信授权的网页,都可以用此方式进行真机调试,可快速分析和逆向 准备工具 一部手机 一台电脑 一条数据线 首先用数据线把手机与电脑连接 然后手机进入到开发者模式并且要是开启状态,具体进入方式,根据机型不同,进入方式不同,自行百度。 进入到开发者选项之后,开启USB调试模式。 然后在手机微信浏览器随便进入一个网站,例如: 再回到电脑浏览器输入: chrome内核的浏览器输入 c...
学习
AI2026-09-01
Deep Agents 01:何为 Agent Harness,以及如何开始
1、本篇任务:完成一份多步骤、带证据的技术调研 普通客服 Agent 的问题短、工具少、输出即时。技术调研或编码任务会持续很久,产生计划、搜索结果、文件和中间结论。Deep Agents 在 LangChain/LangGraph 之上预装规划、虚拟文件系统、上下文压缩和子 Agent,适合这类开放任务。 本课让 Agent 比较两种向量数据库,并交付一份可验证报告。 2、什么时候需要 Deep Agent 满足以下两项以上再考虑:任务...
学习

评论

请登录后发表评论

去登录
加载评论中...

目录