H5实现自定义身份证拍照

背景

由于h5通过 <input type="file /> 方式吊起拍照的为系统相机,给用户的体验并不是很好,没有裁切框,也无法在系统相机上附加 tips 蒙层进行扩展,比如在蒙层上告知用户拍照的注意事项。所以业务上需要实现一个自定义拍照身份证的页面。

前期准备工作

各端兼容性现状

结论:
安卓:chrome53版本之后支持该api。
ios:仅safari11+支持。ios微信内置浏览器、Chrome、Edge等其它浏览器均不支持。

考虑替代方案

以下情况,均需要考虑替代方案:
第一类:当满足下列条件,均需要采用系统相机拍照方案
1.用户不提供摄像头权限。
2.命中以下其中任意一条错误

  • AbortError[中止错误]
  • NotAllowedError[拒绝错误]
  • NotFoundError[找不到错误]
  • NotReadableError[无法读取错误]
  • OverConstrainedError[无法满足要求错误]
  • SecurityError[安全错误]
  • TypeError[类型错误]

3.用户浏览器不支持该api
**
第二类:当ios用户使用非safari浏览器访问h5页面时
由于ios只有safari11+可以吊起后置摄像头视频流,如果ios用户在非safari浏览器打开h5登陆页,都要直接引导用户复制链接到safari浏览器打开,避免接下来无法进行自定义拍照。这里牛客做的就比较好,可以仿照牛客做一个引导按钮。

正片开始

本文不涉及替代方案的兼容逻辑,可以自行在 Promise.reject() 时进行对应处理。

我们的主角是 MediaDevices.getUserMedia() , MDN 对该api的介绍如下

MediaDevices.getUserMedia() 会提示用户给予使用媒体输入的许可,媒体输入会产生一个MediaStream,里面包含了请求的媒体类型的轨道。此流可以包含一个视频轨道(来自硬件或者虚拟视频源,比如相机、视频采集设备和屏幕共享服务等等)、一个音频轨道(同样来自硬件或虚拟音频源,比如麦克风、A/D转换器等等),也可能是其它轨道类型。

能力检测


由于不同浏览器对于标准的实现不一致,需要作api能力的兼容,避免用户浏览器无法正常调用该api。

//访问用户媒体设备的兼容方法
function getUserMedia(constrains) {
    if (navigator.mediaDevices?.getUserMedia) {
        //最新标准API
        return navigator.mediaDevices.getUserMedia(constrains);
    } else if (navigator.webkitGetUserMedia) {
        //webkit内核浏览器
        return navigator.webkitGetUserMedia(constrains);
    } else if (navigator.mozGetUserMedia) {
        //Firefox浏览器
        return navigator.mozGetUserMedia(constrains);
    } else if (navigator.getUserMedia) {
        //旧版API
        return navigator.getUserMedia(constrains);
    }
}
复制代码

在页面上放置一个video元素


<video
   id="video"
   autoPlay
   muted
   playsInline
   style={{
    width: '100%',
  }}
></video>
复制代码

有几个注意点⚠️

iOS 10 Safari 允许自动播放以下两种视频:

  • 无音轨视频;
  • 无声音视频(设置了 muted 属性);

对于这两种类型的视频,可以通过 <video autoplay>video.play() 两种方式来自动播放,无需用户主动操作。但是,如果它们在播放时变得有声音(获取了音轨,或者 muted 属性被取消),Safari 会暂停播放。

  • 只有提供 muted 属性,让视频静音,才可以通过 <video autoplay>video.play() 两种方式来进行播放
  • 必须提供 playsInline 属性,不然在ios上会只播放一帧

调用封装好的getUserMedia,获取用户媒体流


调用时,我们可以给constrains 对象可以多种不同的值,来获取用户设备底层各种不同的媒体流。

  • video: true (默认调取前置摄像头)
  • 为了调取后置摄像头,需要通过 facingMode: { exact: 'environment' } 来进行调用**(如果后置摄像头不存在,则会导致获取媒体流失败
  • 为了获取特定分辨率的视频流,我们可以指定相应的 width height(但这种方式有缺陷,一旦用户设备不存在对于像素流,则会导致获取媒体流失败,所以,我们不对像素进行定制,使用自动获取到的媒体流像素)
/**
 * 该函数需要接受一个video的dom节点作为参数
 */
function getUserMediaStream(videoNode) {
  	/** 
     * 调用api成功的回调函数
     */
    function success(stream, video) {
        return new Promise((resolve, reject) => {
            video.srcObject = stream;

            video.onloadedmetadata = function () {
                video.play();
                resolve();
            };
        });
    }
  
    //调用用户媒体设备,访问摄像头
    return getUserMedia({
        audio: false,
        video: { facingMode: { exact: 'environment' } },
        // video: true,
        // video: { facingMode: { exact: 'environment', width: 1280, height: 720 } },
    })
        .then(res => {
            return success(res, videoNode);
        })
        .catch(error => {
            console.log('访问用户媒体设备失败:', error.name, error.message);
            return Promise.reject();
        });
}

复制代码

当前效果:

增加裁切框和外部阴影


  • 裁切框我们根据需求写到页面中,之后会通过 getBoundingClientRect 获取裁切框的位置进行裁切。
  • 外部阴影使用 box-shadow 即可
<div className={styles['shadow-layer']} style={{ height: `${videoHeight}px` }}>
  <div id="capture-rectangle" className={styles['capture-rectangle']}></div>
</div>
复制代码
@function remB($px) {
    @return ($px/75) * 1rem;
}

.shadow-layer {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  z-index: 1;
  overflow: hidden;

  .capture-rectangle {
    margin: remB(200) auto 0;
    width: remB(700);  // 这里写上我们需要裁切的宽
    height: remB(450); // 这里写上我们需要裁切的高
    border: 1px solid #fff;
    border-radius: remB(20);
    z-index: 2;
    box-shadow: 0 0 0 remB(1000) rgba(0, 0, 0, 0.7); // 外层阴影
  }
}
复制代码

当前效果:

完成实时照片裁切,上传服务端进行OCR识别


裁切用到的是 canvas.getContext('2d).drawImage 的能力。
void ctx.drawImage(image, sx, sy, sWidth, sHeight, dx, dy, dWidth, dHeight);

MDN对该属性说明:
image绘制到上下文的元素。允许任何的 canvas 图像源(CanvasImageSource),例如:CSSImageValue (en-US)HTMLImageElementSVGImageElement (en-US)HTMLVideoElementHTMLCanvasElementImageBitmap 或者OffscreenCanvas

sx可选需要绘制到目标上下文中的,image的矩形(裁剪)选择框的左上角 X 轴坐标。

sy可选需要绘制到目标上下文中的,image的矩形(裁剪)选择框的左上角 Y 轴坐标。

sWidth可选需要绘制到目标上下文中的,image的矩形(裁剪)选择框的宽度。

如果不说明,整个矩形(裁剪)从坐标的sxsy开始,到image的右下角结束。

sHeight可选需要绘制到目标上下文中的,image的矩形(裁剪)选择框的高度。

dx``image的左上角在目标canvas上 X 轴坐标。dy``image的左上角在目标canvas上 Y 轴坐标。

dWidth可选image在目标canvas上绘制的宽度。 允许对绘制的image进行缩放。 如果不说明, 在绘制时image宽度不会缩放。

dHeight可选image在目标canvas上绘制的高度。 允许对绘制的image进行缩放。 如果不说明, 在绘制时image高度不会缩放。

可以我们传入 video 作为 source 进行裁切。
这里要注意

  • sxsy 对应的是距离真实 video 元素的 top left 距离,不是页面中 video 的大小,拿到裁切框位置大小之后,需要做转换,再进行裁切,否则裁切位置会对不上。

/**
* 获取video中对应的真实size
*/
function getXYRatio() {
  // videoHeight为video 真实高度
  // offsetHeight为video css高度
  const { videoHeight: vh, videoWidth: vw, offsetHeight: oh, offsetWidth: ow } = video;

  return {
    yRatio: height => {
      return (vh / oh) * height;
    },
    xRatio: width => {
      return (vw / ow) * width;
    },
  };
}

复制代码

在调用 getUserMediaStream 成功之后,我们开始捕捉视频流,每隔几秒进行截图,发送到服务器。

/** 裁切上传相关核心代码  */
const Photo = () => {
		const [videoHeight, setVideoHeight] = useState(0);
    const ref = useRef(null);

    useEffect(() => {
        const video = document.getElementById('video');
        const rectangle = document.getElementById('capture-rectangle');
        const _canvas = document.createElement('canvas');
        _canvas.style.display = 'block';

        getUserMediaStream(video)
            .then(() => {
                setVideoHeight(video.offsetHeight);
                startCapture();
            })
            .catch(err => {
                showFail({
                  text: '无法调起后置摄像头,请点击相册,手动上传身份证',
                  duration: 6,
                });
            });


        function startCapture() {
          ref.current = setInterval(() => {
            const { yRatio, xRatio } = getXYRatio();
            /** 获取裁切框的位置 */
            const { left, top, width, height } = rectangle.getBoundingClientRect();

            const context = _canvas.getContext('2d');
            _canvas.width = width;
            _canvas.height = height;

            // void ctx.drawImage(image, sx, sy, sWidth, sHeight, dx, dy, dWidth, dHeight);
            context.drawImage(
              video,
              xRatio(left + window.scrollX),
              yRatio(top + window.scrollY),
              xRatio(width),
              yRatio(height),
              0,
              0,
              width,
              height,
            );

            // 获取当前截图的base64编码
            const base64 = _canvas.toDataURL('image/jpeg');
            // 这里可以再根据场景做base64压缩
            // 每2秒调用OCR接口,上传base64到服务端进行识别
          }, 2000);
        }

        /** 清空定时器 */
        return () => clearInterval(ref.current);
    }, []);
}

复制代码

注意:
sx 、 sy 的值是相对根元素的,通过 getBoundingClientRect 拿到的 top 和 left 是相当于视口的,需要加上 scroll 的值。

结语

实际上 getUserMedia  在安卓和 MacOs 上跑起来几乎没有问题,但是社区中对于该 api 的讨论太少了,可能大部分人甚至不知道这个 api 的存在,在 ios 真机上进行调试时,一开始只展示有一帧,便静止了,报错不会给予开发者比较详细的提示,我一开始大部分时间都花在了研究 ios 端为什么无法正常调用该 api 。不过这种业务场景在 app 上应该是比较常见的,本文仅为h5该业务场景的实现方式。

附一张最终效果图:

References

1.iOS13 getUserMedia not working on chrome and edge
stackoverflow.com/questions/6…
bugs.webkit.org/show_bug.cg…
It prevents ALL other browsers on iOS to offer video-conferencing, while Safari can => it’s a nasty anti-competitive behaviour that will for sure be scrutinized by US House Antitrust Committee & EU Commission, and Apple should not accumulate evidence of evil conduct.

2.MDN getUserMedia developer.mozilla.org/zh-CN/docs/…

3.ios10+视频播放新策略 imququ.com/post/new-vi…

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享