该技术方案可用于各浏览器自定义相机开发
相机UI(index.html)
<!DOCTYPE html>
<html lang="zh" prew="-1">
<head>
<meta charset="UTF-8">
<meta name="viewport"
content="user-scalable=no, initial-scale=1, maximum-scale=5, minimum-scale=1, width=device-width" />
<title>自定义相机</title>
<link rel="stylesheet" href="./style.css">
<script src="./tools.js"></script>
<script src="./index.js"></script>
</head>
<body>
<div class="errTip">
<p>Failed to obtain the rear camera of the device. Please try another solution to obtain resources!</p>
<button class="errBtn">GO Back</button>
</div>
<div class="takeOffTip"></div>
<div class="imgBoxDom">
<div class="imgBox">
<img src="./center.png" style="width: 4vw;">
</div>
</div>
<div class="rightBtnBox">
<div class="takeBtn"></div>
<div class="cancleBtn btn"></div>
</div>
<div class="bottomBtnBox">
<div class="reTakeBtn btn bottonSize"></div>
<div class="nextBtn btn bottonSize"></div>
</div>
<div class="loading-css">
Loading...
</div>
</body>
</html>
相机UI样式(style.css)
* {
margin: 0;
padding: 0;
box-sizing: border-box;
border: 0;
}
html,
body {
width: 100%;
height: 100%;
overflow: hidden;
background-color: #000;
color: #fff;
}
.cancleBtn {
padding: 2vw 0;
width: 100%;
}
.takeOffTip {
position: fixed;
padding-top: 2vw;
top: 0;
left: 0;
width: 100%;
font-size: 1.8vw;
text-align: center;
color: #fff;
}
.bottonSize {
height: 100%;
line-height: 6vw;
line-height: 6dvw;
padding: 0 1.5vw;
}
.bottomBtnBox,
.rightBtnBox {
position: fixed;
right: 0;
display: flex;
justify-content: space-between;
align-items: center;
background-color: #000;
z-index: 10;
}
.bottomBtnBox {
bottom: 0;
width: 100%;
height: 6vw;
height: 6dvw;
}
.rightBtnBox {
flex-direction: column;
top: 0;
height: 100%;
width: 6vw;
width: 6dvw;
}
html[prew='-1'] .bottomBtnBox,
html[prew='0'] .bottomBtnBox,
html[prew='-1'] .rightBtnBox,
html[prew='1'] .rightBtnBox,
html[prew='1'] .customer_carema {
display: none;
}
html[prew='1'] .imgBox {
border: 0;
font-size: 0;
opacity: 0;
}
.takeBtn {
padding: 4px;
width: 5vw;
width: 5dvw;
height: 5vw;
height: 5dvw;
background-color: #fff;
border-radius: 50%;
}
.takeBtn::before {
content: '';
display: block;
width: 100%;
height: 100%;
border: 5px solid #000;
background-color: #fff;
border-radius: 50%;
box-sizing: border-box;
}
.rightBtnBox::before {
content: '';
display: block;
}
.btn {
background-color: #000;
text-align: center;
font-size: 1.5vw;
color: #fff;
}
.customer_video,
.carema_img,
.cuteImg {
width: 100%;
height: 100%;
object-fit: cover;
}
.imgBoxDom {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
z-index: 9;
}
.imgBox {
width: var(--carema-box-width);
height: var(--carema-box-height);
border: 2px solid #fff;
display: flex;
justify-content: center;
align-items: center;
font-size: 10vw;
z-index: 10;
border-radius: 2vw;
}
.errTip {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 8888;
display: none;
flex-direction: column;
justify-content: center;
align-items: center;
background-color: #000;
}
.errTip>p {
padding-bottom: 20px;
color: #fff;
}
.errTip button {
padding: 10px 30px;
}
html[prew='2'] .errTip {
display: flex;
}
html[loaded='1'] .loading-css {
display: none;
}
.loading-css {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
background-color: #000;
z-index: 9999;
}
.loading-css::before {
margin-bottom: 10px;
content: '';
width: 50px;
height: 50px;
display: inline-block;
border: 3px solid #f3f3f3;
border-top: 3px solid rgb(160, 155, 155);
border-radius: 50%;
animation: loading-360 0.8s infinite linear;
}
@keyframes loading-360 {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
调试UI(carema.html)
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<meta name="viewport"
content="user-scalable=no, initial-scale=1, maximum-scale=5, minimum-scale=1, width=device-width" />
<title>调试相机</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
border: 0;
}
img {
max-width: 100%;
}
.btnList {
padding: 10px;
}
label[type='file'],
button {
padding: 0 10px;
height: 32px;
line-height: 32px;
display: inline-block;
font-size: 14px;
appearance: auto;
border: 1px solid #999;
background-color: #dcdcdc;
}
label>input {
font-size: 0;
width: 0;
height: 0;
overflow: hidden;
}
.showImg {
padding: 5px;
display: flex;
flex-wrap: wrap;
}
.showImg>.box {
width: 33.33%;
padding: 5px;
}
.showImg>.box>.img {
width: 100%;
height: 20vw;
overflow: hidden;
border-radius: 10px;
border: 2px solid #888;
}
.showImg>.box>.img>img {
width: 100%;
height: 100%;
object-fit: cover;
}
html,
body {
height: 100%;
height: 100%;
}
body {
display: flex;
flex-direction: column;
}
.showImg {
flex: 1;
overflow-x: hidden;
}
</style>
</head>
<body>
<div class="btnList">
<button onclick="openCarema('HK_ID')">COMM_ID_IMG</button>
<button onclick="openCarema('LANDING')">LANDING_IMG</button>
<label name="upload" type="file">
LOCAL_IMG
<input type="file" id="upload">
</label>
</div>
<div class="showImg" id="showImg"></div>
</body>
<script>
function fileToBase64(file) {
return new Promise((resolve, reject) => {
// 创建一个新的 FileReader 对象
var reader = new FileReader();
// 读取 File 对象
reader.readAsDataURL(file);
// 加载完成后
reader.onload = function () {
// 将读取的数据转换为 base64 编码的字符串
var base64String = reader.result.split(",")[1];
// 解析为 Promise 对象,并返回 base64 编码的字符串
resolve(base64String);
};
// 加载失败时
reader.onerror = function () {
reject(new Error("Failed to load file"));
};
});
}
function showImg(url) {
var showImgDom = document.getElementById('showImg');
var img = document.createElement('img');
img.src = `data:image/jpeg;base64,${url}`;
var div = document.createElement('div');
var cDiv = document.createElement('div');
div.append(cDiv);
cDiv.append(img);
div.className = 'box';
cDiv.className = "img";
showImgDom.insertBefore(div, showImgDom.firstChild);
}
document.getElementById('upload').addEventListener('change', function ($event) {
var file = $event.target.files[0];
fileToBase64(file).then(showImg);
})
function openCarema(idType) {
var openId = Date.now() + '';
window.open(`./index.html?openId=${openId}&idType=${idType}&isDev=1`);
window.addEventListener('message', function (res) {
var resOpenId = res.data.openId;
var mothod = res.data.mothod;
var file = res.data.imgUrl;
console.log(resOpenId, mothod, file);
if (mothod === "success_file" && openId === resOpenId) fileToBase64(file).then(showImg);
})
}
</script>
</html>
相机逻辑基础(index.js)
function WbCRM() {
this.body = document.body;
this.html = document.documentElement;
this.takeBtn = document.querySelector('.takeBtn');
this.imgBox = document.querySelector('.imgBox');
this.reTakeBtn = document.querySelector('.reTakeBtn');
this.cancleBtn = document.querySelector('.cancleBtn');
this.nextBtn = document.querySelector('.nextBtn');
var errBtn = document.querySelector('.errBtn');
this.video = null;
this.err = null;
this.fullImg = null;
this.file = '';
this.idType = '';
this.isDev = false;
this.stream = null;
this.openId = '';
this.ratio = window.devicePixelRatio || 1;
this.videoWidth = this.body.clientWidth * this.ratio;
this.videoHeight = this.body.clientHeight * this.ratio;
this.html.setAttribute('prew', '-1');
var isMp3 = !(navigator.userAgent.match(/Firefox/));
var audio = new Audio();
audio.autoplay = isMp3 ? './shutter.mp3' : './shutter.ogg';
this.audio = audio;
console.log(isMp3,audio);
this.mediaDevices = (navigator.mediaDevices && navigator.mediaDevices.getUserMedia) ?
navigator.mediaDevices : ((navigator.mozGetUserMedia || navigator.webkitGetUserMedia) ? {
getUserMedia: function (c) {
return new Promise(function (y, n) {
(navigator.mozGetUserMedia ||
navigator.webkitGetUserMedia).call(navigator, c, y, n);
});
}
} : null);
this.setDom();
this.setCarema();
this.takeBtn.addEventListener('click', this.takePhoto.bind(this));
this.nextBtn.addEventListener('click', this.next.bind(this));
this.reTakeBtn.addEventListener('click', this.reTake.bind(this));
this.cancleBtn.addEventListener('click', this.cancle.bind(this));
errBtn.addEventListener('click', this.openErro.bind(this));
}
WbCRM.prototype.openErro = function () {
this.sendMsg('open_erro');
}
WbCRM.prototype.cancle = function () {
this.removeStream();
this.sendMsg('off_carema');
}
WbCRM.prototype.next = function () {
if (this.fullImg) this.fullImg.remove();
this.removeStream();
this.sendMsg('success_file');
}
WbCRM.prototype.reTake = function () {
this.file = null;
this.err = null;
if (this.fullImg) this.fullImg.remove();
this.html.setAttribute('loaded', 0);
this.removeStream();
this.setCarema();
}
WbCRM.prototype.cutImage = function () {
var boxWidth = this.imgBox.clientWidth * this.ratio;
var boxHeight = this.imgBox.clientHeight * this.ratio;
var vLeft = (this.videoWidth - boxWidth) / 2;
var vTop = (this.videoHeight - boxHeight) / 2;
var nCanvas = wbCRMTools.drawHighDefinitionImg(boxWidth, boxHeight);
var nCtx = nCanvas.getContext('2d');
nCtx.drawImage(this.fullImg, -vLeft, -vTop);
var cutImage = nCtx.getImageData(0, 0, boxWidth, boxHeight);
wbCRMTools.changeImgData(cutImage?.data || [], this.idType || '');
nCtx.putImageData(cutImage, 0, 0);
reImgUrl = nCanvas.toDataURL('image/jpeg');
var cImg = document.createElement('img');
cImg.src = reImgUrl;
this.file = wbCRMTools.canvas2File(reImgUrl);
wbCRMTools.clearCanvas(nCtx, nCanvas);
cImg.className = "cuteImg";
this.imgBox.append(cImg);
this.html.setAttribute('prew', '1');
this.removeStream();
}
WbCRM.prototype.takePhoto = function () {
var gCanvas = wbCRMTools.drawHighDefinitionImg(this.videoWidth, this.videoHeight);
var originalCtx = gCanvas.getContext('2d');
originalCtx.drawImage(this.video, 0, 0, this.videoWidth, this.videoHeight);
var imgUrl = gCanvas.toDataURL('image/jpeg');
var fullImg = document.createElement("img");
fullImg.className = "carema_img";
fullImg.src = imgUrl;
this.fullImg = fullImg;
this.body.append(fullImg);
wbCRMTools.clearCanvas(originalCtx, gCanvas);
this.audio.play();
fullImg.onload = this.cutImage.bind(this);
}
WbCRM.prototype.sendMsg = function (mothod) {
this.audio.remove();
const origin = this.isDev ? undefined : window.location.origin;
window.opener.postMessage({ mothod: mothod, file: this.file, openId: this.openId, error: this.err }, origin);
window.close();
}
WbCRM.prototype.removeStream = function () {
var self = this;
if (self.stream) {
self.stream.getTracks().forEach(function (track) {
if (track.readyState === 'live') track.stop();
self.stream.removeTrack(track);
});
}
if (this.video) this.video.remove();
var cuteImgList = document.querySelectorAll('.cuteImg');
cuteImgList.forEach(function (dom) {
dom.remove();
})
}
WbCRM.prototype.setDom = function () {
this.openId = wbCRMTools.getUrlParam('openId');
var okText = wbCRMTools.getUrlParam('continue');
var cancelText = wbCRMTools.getUrlParam('cancel');
var retakeText = wbCRMTools.getUrlParam('retake');
var idType = wbCRMTools.getUrlParam('idType') || '';
var takeOffTip = wbCRMTools.getUrlParam('takeOffTip');
const isDev = wbCRMTools.getUrlParam('isDev');
this.isDev = isDev === '1';
this.nextBtn.innerText = okText || 'Cuntinue';
this.cancleBtn.innerText = cancelText || 'Cancel';
this.reTakeBtn.innerText = retakeText || 'Retake';
document.querySelector('.takeOffTip').innerHTML = takeOffTip;
this.html.setAttribute('loaded', 0);
this.html.style.setProperty('--carema-box-width', '64.512vw');
this.html.style.setProperty('--carema-box-height', '40.6789vw');
if (idType === "LANDING") {
this.html.style.setProperty('--carema-box-width', '51.2vw');
this.html.style.setProperty('--carema-box-height', '44.5935vw');
}
this.idType = idType;
}
WbCRM.prototype.setVideo = function (stream) {
var video = document.createElement('video');
video.setAttribute('autoplay', 'autoplay');
video.setAttribute('playsinline', 'playsinline');
video.className = 'customer_video';
this.video = video;
this.stream = stream;
this.body.append(video);
var self = this;
video.onloadedmetadata = function (e) {
self.stream = stream;
self.loaded = true;
self.html.setAttribute('loaded', 1);
};
video.onplay = function () {
self.html.setAttribute('prew', '0');
}
// as window.URL.createObjectURL() is deprecated, adding a check so that it works in Safari.
// older browsers may not have srcObject
if ("srcObject" in video) {
video.srcObject = stream;
} else {
// using URL.createObjectURL() as fallback for old browsers
video.src = window.URL.createObjectURL(stream);
}
}
WbCRM.prototype.setCarema = function () {
const videoConf = this.isDev ? {} : {
width: { min: 1024, ideal: 2360, max: 2732 },
height: { min: 776, ideal: 1640, max: 2048 },
facingMode: { exact: "environment" }
}
var self = this;
this.mediaDevices.getUserMedia({
audio: false,
video: videoConf
}).then(this.setVideo.bind(this)).catch(function (error) {
self.err = error.toString();
self.html.setAttribute('prew', '2');
self.html.setAttribute('loaded', '1');
})
}
window.addEventListener('load', function () {
var wbCRM = new WbCRM();
window.addEventListener('visibilitychange', function () {
wbCRM.removeStream();
window.close();
});
});
图片出路和文件生成工具(tools.js)
var wbCRMTools = {
drawHighDefinitionImg: function (width, height) {
const canvas = document.createElement('canvas');
canvas.style.width = width + 'px';
canvas.style.height = height + 'px';
canvas.width = width;
canvas.height = height;
return canvas;
},
clearCanvas: function (ctx, canvas) {
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.beginPath();
canvas.height = 0;
canvas.width = 0;
canvas.remove();
canvas.parentNode?.removeChild(canvas);
},
changeImgData: function (data, idType) {
const isGrayscale = ['PASSPORT', 'LANDING', 'ENTRYPERMIT', 'SUP_LEGAL_ID'].some(imgType => idType.indexOf(imgType) !== -1);
let contrast = 35;
const thereshold = 20;
if ('LANDING' === idType) contrast = 45;
// gaussBlur will use in the feature, cancel this fun now, don`t delete please
// this.gaussBlur(imageData, 1);
// If MacId and HK-LANDING change cavans-img-code.
const factor = (255 + contrast) / (255.01 - contrast); //add .1 to avoid /0 error
const denominator = 1 / (1 - contrast / 255) - 1;
const setCV = cv => cv + (cv - thereshold) * denominator;
const setCTV = cv => cv + (cv - thereshold) * contrast / 255;
const getRGB = cv => factor * (cv - 128) + 128;
// Data array data-length.
const len = data?.length || 0;
// loop value to change cavans imgData;
for (let index = 0; index < len; index += 4) {
let R = data[index]; //r value
let G = data[index + 1]; //g value
let B = data[index + 2] //b value
if (contrast || thereshold) {
R = getRGB(R); //r value
G = getRGB(G); //g value
B = getRGB(B); //b value
}
const isColorNum = index % 4 === 0;
if (isColorNum) {
R = contrast ? setCV(R) : setCTV(R);
G = contrast ? setCV(G) : setCTV(G);
B = contrast ? setCV(B) : setCTV(B);
if (isGrayscale) {
const vNum = Math.round((R + G + B) / 3);
R = vNum;
G = vNum;
B = vNum;
data[index + 3] = 255;
}
data[index] = R;
data[index + 1] = G;
data[index + 2] = B;
}
}
},
getUrlParam: function (urlKey) {
var url = window.location.search;
var reg = new RegExp("(^|&)" + urlKey + "=([^&]*)(&|$)");
var result = url.substring(1).match(reg);
return result ? decodeURIComponent(result[2]) : null;
},
canvas2File: function (dataUrl) {
let arr = dataUrl.split(','),
mime = arr[0].match(/:(.*?);/)[1],
bstr = atob(arr[1]),
n = bstr.length,
u8arr = new Uint8Array(n);
while (n--) {
u8arr[n] = bstr.charCodeAt(n);
}
const nowId = Date.now();
const fileName = `takePhoto_${nowId}.jpeg`;
const blob = new Blob([u8arr], { type: mime, name: fileName });
blob.lastModifiedDate = new Date();
return new File([blob], fileName, { type: "image/jpeg" });
}
}
文件目录
效果图