H5实现自定义身份证拍照

demo源码:https://github.com/ericlee33/h5-id-card ,如果觉得有帮助的话,欢迎点个star👏

背景

由于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。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
//访问用户媒体设备的兼容方法
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元素


1
2
3
4
5
6
7
8
9
<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(但这种方式有缺陷,一旦用户设备不存在对于像素流,则会导致获取媒体流失败,所以,我们不对像素进行定制,使用自动获取到的媒体流像素)
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    /**
    * 该函数需要接受一个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 即可
    1
    2
    3
    <div className={styles['shadow-layer']} style={{ height: `${videoHeight}px` }}>
    <div id="capture-rectangle" className={styles['capture-rectangle']}></div>
    </div>
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    @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
https://developer.mozilla.org/zh-CN/docs/Web/API/CanvasRenderingContext2D/drawImage

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

  • sxsy 对应的是距离真实 video 元素的 top left 距离,不是页面中 video 的大小,拿到裁切框位置大小之后,需要做转换,再进行裁切,否则裁切位置会对不上。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19

    /**
    * 获取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 成功之后,我们开始捕捉视频流,每隔几秒进行截图,发送到服务器。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
/** 裁切上传相关核心代码  */
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
https://stackoverflow.com/questions/63084076/ios13-getusermedia-not-working-on-chrome-and-edge
https://bugs.webkit.org/show_bug.cgi?id=208667
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 https://developer.mozilla.org/zh-CN/docs/Web/API/MediaDevices/getUserMedia

3.ios10+视频播放新策略 https://imququ.com/post/new-video-policies-for-ios10.html

Welcome to my other publishing channels