前端React实现相机,支持裁剪照片并下载

415 阅读3分钟

发生什么事了

最近接到一个需求,要在前端调用摄像头,拍下照片然后进行裁剪(因为要进行图像识别所以对图片范围有要求)。

众所周知,H5 提供了调用媒体设备的接口,那么我们的思路就比较清晰了,首先调用摄像头获取视频流,然后将视频流设置给一个 video 标签显示图像内容。当拍照的时候,我们在 video 中截取一帧画在 canvas 上面,然后再在canvas上面进行裁剪,最后将裁剪的部分转为 base64 格式用来保存/上传。

主要方案

  1. 调用媒体设备获取视频流,交给 video 标签,展示摄像头内容
  2. 将 video 的一帧画在 canvas 上面,达成拍照效果
  3. 在 canvas 上面裁剪,然后转为 base64 格式输出

(不知道拍点什么就用手指头盖住了🤪) image.png

image.png

代码

调用摄像头

// 判断是否支持
if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia) {
  navigator.mediaDevices
    .getUserMedia({
      // 不需要音频
      audio: false,
      // 配置分辨率
      video: {
        width: {
          ideal: 300,
        },
        height: {
          ideal: 300,
        },
        // 帧率
        // frameRate: { ideal: 10, max: 15 } }
        // 前置/后置摄像头
        // facingMode: "user", // 后置为 environment
      },
    })
    .then(function (stream) {
      const video = document.getElementById('video');
      // video 显示摄像头影像
      video.srcObject = stream;
      // 需要调用播放 API
      video.play();
    })
    .catch((e) => {
      console.error(e);
      console.log('未获得授权');
    });
} else {
  console.log('未获取到权限');
}

这里我们使用了 Navigator.mediaDevices.getUserMedia API 获取视频流,这个接口返回一个 Promise 对象,这时候用户会收到权限申请,如果授权我们会取到一个 MediaStream 对象,然后赋值给 video 标签的 srcObject 属性,最后调用播放就可以看到影像了。

tips:

  • 视频流的分辨率最好和 video 标签比例相同,否则会导致黑边的情况。
  • 同比例的情况下分辨率越高影像越清晰。
  • 适当降低帧率可以降低资源占用。

媒体API 文档

拍照

我们将 video 的一帧画在 canvas 上面,然后就可以轻易的导出常见的图片格式

const video = document.querySelector('#video');
const canvas = document.querySelector('#canvas');
const ctx = canvas.getContext('2d');
video && ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
const url = canvas.toDataURL('image/png');
// 下载图片
const aElement = document.createElement("a");
aElement.href = url
aElement.download = "测试照片";
document.body.append(aElement);
aElement.click();
document.body.removeChild(aElement);

tips:

  • 这里需要注意 canvas 绘图的范围的比例要和前面的 video 一致,否则会导致图片拉伸变形

图片裁剪

这里介绍一个库 react-cropper,它基于 cavas 开发,支持传入图片 url 进行裁剪,同时支持导出。


<Cropper
ref={cropperRef}
style={{
  width: "300px",
  height: "300px",
  position: "absolute",
  top: 0,
  left: 0,
  visibility: visible.showCanvas ? "visible" : "hidden",
}}
zoomTo={1}
initialAspectRatio={1}
preview=".img-preview"
src={imgSrc}
viewMode={1}
minCropBoxHeight={100}
minCropBoxWidth={100}
background={false}
responsive={true}
autoCropArea={1}
checkOrientation={false}
guides={true}
/>

// 导出
const url = cropperRef.current.cropper
          .getCroppedCanvas()
          .toDataURL("image/png");

当然大佬们也可以自己实现类似功能,直接在 canvas 上面实现裁剪功能

完整组件代码

import React, { useEffect, useState, useRef } from 'react';
import Cropper, { ReactCropperElement } from 'react-cropper';
import 'cropperjs/dist/cropper.css';
import './style.css';

export default function App() {
  const [visible, setVisible] = useState({
    showVideo: true,
    showResult: false,
  });
  const [stream, setStream] = useState();
  const [imgSrc, setImgSrc] = useState('');
  const cropperRef = useRef(null);

  useEffect(() => {
    if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia) {
      navigator.mediaDevices
        .getUserMedia({
          audio: false,
          video: {
           // 分辨率
            width: {
              ideal: 300,
            },
            height: {
              ideal: 300,
            },
            // 帧率
            // frameRate: { ideal: 10, max: 15 } }
          },
        })
        .then(function (stream) {
          setStream(stream);
          const video = document.getElementById('video');
          video.srcObject = stream;
          video.play();
        })
        .catch((e) => {
          console.error(e);
          console.log('未获得授权');
        });
    } else {
      console.log('未获取到权限');
    }
  }, []);

  const takePhoto = async () => {
    if (!stream) return;
    try {
      const video = document.querySelector('#video');
      const canvas = document.getElementById('canvas');
      const ctx = canvas.getContext('2d');
      video && ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
      const url = canvas.toDataURL('image/png');
      setImgSrc(url);
      setVisible({
        showVideo: false,
        showResult: true,
      });
    } catch (err) {
      console.error('takePhoto failed: ', err);
    }
  };

  const save = () => {
    const aElement = document.createElement('a');
    aElement.href = cropperRef.current.cropper
      .getCroppedCanvas()
      .toDataURL('image/png');
    aElement.download = '测试照片';
    document.body.append(aElement);
    aElement.click();
    document.body.removeChild(aElement);
  };

  const replay = () => {
    setVisible({
      showVideo: true,
      showResult: false,
    });
  };

  return (
    <div class="wrapper">
      <video
        id="video"
        width={300}
        height={300}
        style={{ visibility: visible.showVideo ? 'visible' : 'hidden' }}
      />
      <canvas
        id="canvas"
        width="300"
        height="300"
        style={{ visibility: 'hidden' }}
      />
      <Cropper
        ref={cropperRef}
        style={{
          width: '300px',
          height: '300px',
          position: 'absolute',
          top: 0,
          left: 0,
          visibility: visible.showResult ? 'visible' : 'hidden',
        }}
        zoomTo={1}
        initialAspectRatio={1}
        preview=".img-preview"
        src={imgSrc}
        viewMode={1}
        minCropBoxHeight={100}
        minCropBoxWidth={100}
        background={false}
        responsive={true}
        autoCropArea={1}
        checkOrientation={false}
        guides={true}
      />
      <button
        style={{ position: 'absolute', left: '300px', top: 0 }}
        onClick={takePhoto}
      >
        拍照
      </button>
      {visible.showResult && (
        <>
          <button
            style={{ position: 'absolute', left: '300px', top: 0 }}
            onClick={save}
          >
            保存
          </button>
          <button
            style={{ position: 'absolute', left: '300px', top: '40px' }}
            onClick={replay}
          >
            重拍
          </button>
        </>
      )}
    </div>
  );
}


完整代码地址 stackbltz