fix(h5): optimize camera flipping and angle haptics, refresh splash branding
This commit is contained in:
+119
-35
@@ -402,6 +402,23 @@
|
||||
var IS_MOCK = /[?&]mock=1/.test(location.search); // mock:不加载 face-api、不跑检测,analyze 直返内置样本
|
||||
var camVideo = $('#cam-video'), camFile = $('#cam-file'), albumFile = $('#album-file');
|
||||
var camStream = null, camLive = false, camFallback = false, camSeqCam = 0; // camSeqCam:相机启动令牌,防止迟到的授权回调泄漏摄像头
|
||||
var camBusy = false, camReadyTimer = null, camOpenTimer = null;
|
||||
var camFrozen = document.createElement('canvas');
|
||||
camFrozen.id = 'cam-frozen';
|
||||
camVideo.parentNode.insertBefore(camFrozen, camVideo);
|
||||
function freezeCamera() {
|
||||
if (!camLive || !camVideo.videoWidth) return;
|
||||
camFrozen.width = camVideo.videoWidth;
|
||||
camFrozen.height = camVideo.videoHeight;
|
||||
camFrozen.getContext('2d').drawImage(camVideo, 0, 0);
|
||||
camFrozen.classList.toggle('mirror', camMirrored);
|
||||
$('#s-cam').classList.add('cam-switching');
|
||||
}
|
||||
function setCameraBusy(busy) {
|
||||
camBusy = busy;
|
||||
$('.cam-flip').disabled = busy;
|
||||
$('.cam-flip').setAttribute('aria-busy', String(busy));
|
||||
}
|
||||
var photos = []; // 三连拍(dataURL),拍摄中与 CAM_STEPS 同序;送检前经 photosByView 重排为 [正脸, 左脸, 右脸]
|
||||
var captureDiagnostics = [], holdState = null;
|
||||
|
||||
@@ -428,44 +445,67 @@
|
||||
throw err;
|
||||
});
|
||||
}
|
||||
// 相机预热:部分设备开头 1~2s 以低分辨率出画(画面显脸巨大),等分辨率稳定两拍再放开取景与检测
|
||||
// 首帧可用即展示,不再额外等待三轮 300ms。定时器由 stopCamera 统一取消。
|
||||
function waitVideoStable(done) {
|
||||
var lastW = 0, stableN = 0, tries = 0;
|
||||
var t = setInterval(function () {
|
||||
var w = camVideo.videoWidth;
|
||||
tries++;
|
||||
if (w && w === lastW) {
|
||||
if (++stableN >= 2) { clearInterval(t); done(); }
|
||||
} else { stableN = 0; lastW = w; }
|
||||
if (tries > 40) { clearInterval(t); done(); } // 12s 兜底,异常也不卡死
|
||||
}, 300);
|
||||
var started = Date.now();
|
||||
camReadyTimer = setInterval(function () {
|
||||
var ready = camVideo.readyState >= 2 && camVideo.videoWidth > 0 && camVideo.videoHeight > 0;
|
||||
if (ready || Date.now() - started >= 12000) {
|
||||
clearInterval(camReadyTimer); camReadyTimer = null;
|
||||
done(ready);
|
||||
}
|
||||
}, 50);
|
||||
}
|
||||
function startCamera() {
|
||||
if (camStream) { stopCamera(); }
|
||||
function startCamera(restoreFacing, keepFrozen) {
|
||||
stopCamera(keepFrozen);
|
||||
camFallback = false;
|
||||
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { enableFallback(); return; }
|
||||
setCameraBusy(true);
|
||||
if (keepFrozen) showCamStatus('正在切换摄像头…');
|
||||
camSeqCam++;
|
||||
var token = camSeqCam;
|
||||
function failed() {
|
||||
if (token !== camSeqCam) return;
|
||||
stopCamera(keepFrozen);
|
||||
if (restoreFacing) {
|
||||
camFacing = restoreFacing;
|
||||
camMirrored = camFacing === 'user';
|
||||
toast('切换失败,正在恢复原摄像头');
|
||||
startCamera(null, keepFrozen);
|
||||
} else { stopCamera(); enableFallback(); }
|
||||
}
|
||||
// 翻转/恢复不涉及首次权限询问,超时恢复;迟到的流仍会被令牌检查释放。
|
||||
if (keepFrozen) camOpenTimer = setTimeout(failed, 12000);
|
||||
getCamStream().then(function (stream) {
|
||||
if (token !== camSeqCam) { stream.getTracks().forEach(function (t) { t.stop(); }); return; } // 期间已退出拍照页:立即释放
|
||||
clearTimeout(camOpenTimer); camOpenTimer = null;
|
||||
camStream = stream;
|
||||
camVideo.srcObject = stream;
|
||||
var track = stream.getVideoTracks()[0];
|
||||
var actualFacing = track && track.getSettings && track.getSettings().facingMode;
|
||||
if (actualFacing === 'user' || actualFacing === 'environment') {
|
||||
if (restoreFacing && actualFacing !== camFacing) { failed(); return; }
|
||||
camFacing = actualFacing;
|
||||
}
|
||||
camMirrored = camFacing === 'user';
|
||||
applyMirror();
|
||||
// iOS 来电/切后台等系统打断会结束 track:挂 ended 监听走自动重连
|
||||
stream.getTracks().forEach(function (t) {
|
||||
t.onended = function () { onCamTrackEnded(token); };
|
||||
});
|
||||
waitVideoStable(function () { // 预热完成前不出实时画面(静态示意底图还在),避免开头"大脸"闪变
|
||||
waitVideoStable(function (ready) {
|
||||
if (token !== camSeqCam) return;
|
||||
if (!ready) { failed(); return; }
|
||||
camLive = true;
|
||||
$('#s-cam').classList.add('cam-live');
|
||||
$('#s-cam').classList.remove('cam-switching');
|
||||
camFrozen.width = camFrozen.height = 0;
|
||||
setCameraBusy(false);
|
||||
$('#cam-status').classList.remove('show');
|
||||
beginDetect(); // 确认相机可用后才加载检测模型(无相机权限设备不白加载)
|
||||
maybeStartDetect(); // 模型已就绪时即刻开跑检测循环
|
||||
});
|
||||
}).catch(function () {
|
||||
if (token !== camSeqCam) return;
|
||||
enableFallback();
|
||||
});
|
||||
}).catch(failed);
|
||||
}
|
||||
// track 被系统结束(iOS 打断)且页面仍在拍照前台:toast 后自动重连,重连失败走 startCamera 内的 enableFallback
|
||||
function onCamTrackEnded(token) {
|
||||
@@ -475,8 +515,15 @@
|
||||
toast('摄像头连接中断,正在重连');
|
||||
startCamera();
|
||||
}
|
||||
function stopCamera() {
|
||||
function stopCamera(keepFrozen) {
|
||||
camSeqCam++; // 使进行中的 getUserMedia 回调失效
|
||||
clearInterval(camReadyTimer); clearTimeout(camOpenTimer);
|
||||
camReadyTimer = camOpenTimer = null;
|
||||
setCameraBusy(false);
|
||||
if (!keepFrozen) {
|
||||
$('#s-cam').classList.remove('cam-switching');
|
||||
camFrozen.width = camFrozen.height = 0;
|
||||
}
|
||||
stopDetect(); // 检测循环/无进展提示一并收尾
|
||||
$('#cam-status').classList.remove('show');
|
||||
if (camStream) { camStream.getTracks().forEach(function (t) { t.stop(); }); camStream = null; }
|
||||
@@ -746,9 +793,8 @@
|
||||
}
|
||||
return faceApiPromise;
|
||||
}
|
||||
var detRunning = false, detTimer = null; // 检测循环
|
||||
var detRunning = false, detTimer = null, detGeneration = 0, detInFlight = false; // 检测循环
|
||||
var okSince = 0; // 当前连续 ok 起点(0=未在累积)
|
||||
var readyHapticAt = 0; // 抖动重试时最多每两秒提醒一次
|
||||
var nudgeTimer = null; // 12s 无进展提示
|
||||
var capturing = false; // 白屏补光抓帧中(暂停姿态判定)
|
||||
var lastHint = ''; // 提示文案去重
|
||||
@@ -810,7 +856,7 @@
|
||||
function resetDetectState() {
|
||||
okSince = 0;
|
||||
holdState = null;
|
||||
readyHapticAt = 0;
|
||||
resetAngleHaptic();
|
||||
missFrames = 0;
|
||||
oppFrames = 0;
|
||||
lastHint = '';
|
||||
@@ -835,34 +881,43 @@
|
||||
function maybeStartDetect() {
|
||||
if (detRunning || IS_MOCK || detectDisabled || !modelsOk || !camLive || currentScreen !== 's-cam') return;
|
||||
detRunning = true;
|
||||
detGeneration++;
|
||||
syncCamMode();
|
||||
detTick();
|
||||
detTick(detGeneration);
|
||||
}
|
||||
function stopDetect() {
|
||||
resetAngleHaptic();
|
||||
detRunning = false;
|
||||
detGeneration++;
|
||||
clearTimeout(detTimer);
|
||||
clearTimeout(nudgeTimer);
|
||||
hideNudge();
|
||||
}
|
||||
// 递归 setTimeout 而非 setInterval:检测是异步的,避免帧与帧堆叠
|
||||
function detTick() {
|
||||
if (!detRunning) return;
|
||||
function detTick(generation) {
|
||||
if (!detRunning || generation !== detGeneration) return;
|
||||
detTimer = setTimeout(function () {
|
||||
if (!detRunning) return;
|
||||
detectFrame().then(detTick, detTick);
|
||||
if (!detRunning || generation !== detGeneration) return;
|
||||
if (detInFlight) { detTick(generation); return; }
|
||||
detInFlight = true;
|
||||
function finished() {
|
||||
detInFlight = false;
|
||||
detTick(generation);
|
||||
}
|
||||
Promise.resolve().then(detectFrame).then(finished, finished);
|
||||
}, DETECT_INTERVAL);
|
||||
}
|
||||
function detectFrame() {
|
||||
if (shutterLock || capturing || !camLive) return Promise.resolve(); // 抓帧/流程推进中不判姿态
|
||||
var step = camStep, seq = camSeq; // 快照:检测是异步的,帧落地时步骤/流程可能已变
|
||||
var step = camStep, seq = camSeq, generation = detGeneration; // 旧镜头推理结果不能污染新镜头
|
||||
return faceapi
|
||||
.detectAllFaces(camVideo, new faceapi.TinyFaceDetectorOptions({ inputSize: DETECT_INPUT_SIZE, scoreThreshold: DETECT_SCORE }))
|
||||
.withFaceLandmarks()
|
||||
.then(function (d) {
|
||||
if (step !== camStep || seq !== camSeq) return; // 跨步骤陈帧/流程已重置:丢弃
|
||||
if (step !== camStep || seq !== camSeq || generation !== detGeneration) return;
|
||||
if (detRunning && !shutterLock && !capturing) onDetect(d);
|
||||
})
|
||||
.catch(function () { if (step === camStep && seq === camSeq) { holdState = null; okSince = 0; } }); // Unchecked frames break continuous readiness.
|
||||
.catch(function () { if (step === camStep && seq === camSeq && generation === detGeneration) { holdState = null; okSince = 0; resetAngleHaptic(); } });
|
||||
}
|
||||
// 人脸框中心相对取景中线的水平偏移(占屏宽比例);按 object-fit:cover + 预览缩放换算到屏幕,镜像只影响符号
|
||||
function faceOffsetX(box) {
|
||||
@@ -887,11 +942,34 @@
|
||||
/* camera-haptic-pure-begin */
|
||||
function captureHaptic(nav) {
|
||||
// Unsupported/blocked vibration must never interrupt automatic capture.
|
||||
try { return typeof nav.vibrate === 'function' && nav.vibrate([70, 40, 70]) !== false; }
|
||||
try { return !!nav && typeof nav.vibrate === 'function' && nav.vibrate(30) !== false; }
|
||||
catch (e) { return false; }
|
||||
}
|
||||
var angleHapticState = { since: null, lastFrame: null, lastBuzz: null, vibrating: false };
|
||||
function resetAngleHaptic() {
|
||||
if (angleHapticState.vibrating) {
|
||||
try { if (typeof navigator.vibrate === 'function') navigator.vibrate(0); } catch (_) {}
|
||||
}
|
||||
angleHapticState.since = angleHapticState.lastFrame = null;
|
||||
angleHapticState.vibrating = false;
|
||||
}
|
||||
function updateAngleHaptic(invalid, now) {
|
||||
if (!invalid) { resetAngleHaptic(); return; }
|
||||
var s = angleHapticState;
|
||||
// A missing/failed frame breaks continuity; a stalled detector must not
|
||||
// produce a reminder as though it observed the user throughout the gap.
|
||||
if (s.since === null || now < s.lastFrame || now - s.lastFrame > 2000) s.since = now;
|
||||
s.lastFrame = now;
|
||||
if (now - s.since >= 1000 && (s.lastBuzz === null || now - s.lastBuzz >= 4000)) {
|
||||
s.lastBuzz = now;
|
||||
s.vibrating = captureHaptic(navigator);
|
||||
}
|
||||
}
|
||||
/* camera-haptic-pure-end */
|
||||
function onDetect(d) {
|
||||
if (shutterLock || capturing || camBusy || !camLive || currentScreen !== 's-cam' || document.hidden) {
|
||||
resetAngleHaptic(); return;
|
||||
}
|
||||
var count = Array.isArray(d) ? d.length : d ? 1 : 0;
|
||||
if (Array.isArray(d)) d = d[0];
|
||||
var hint = '', ok = false;
|
||||
@@ -900,6 +978,7 @@
|
||||
if (noFace) {
|
||||
hint = '未检测到人脸';
|
||||
} else if (!hasFace) {
|
||||
resetAngleHaptic();
|
||||
holdState = null; okSince = 0;
|
||||
return; // Keep the hint quiet, but never count a missing frame as stable.
|
||||
} else {
|
||||
@@ -926,6 +1005,7 @@
|
||||
hint = verdict.hint; ok = verdict.ok;
|
||||
window.__dbgRatio = ratio; // 自动化校准/测试用(检测框高/视频高)
|
||||
}
|
||||
updateAngleHaptic(!!(verdict && verdict.reason === 'angle'), Date.now());
|
||||
setMask(ok ? 'ok' : (noFace ? 'no-face' : ''));
|
||||
// 合格时「保持不动」只显示不播报:随即抓拍,「拍摄完成」会截断它
|
||||
setHint(ok ? '保持不动' : hint, ok, ok);
|
||||
@@ -934,7 +1014,6 @@
|
||||
holdState = held.state;
|
||||
if (holdState.frames === 1) {
|
||||
okSince = holdState.since;
|
||||
if (!readyHapticAt || okSince - readyHapticAt >= 2000) { readyHapticAt = okSince; captureHaptic(navigator); }
|
||||
}
|
||||
if (held.ready) { okSince = 0; holdState = null; doCapture(); }
|
||||
}
|
||||
@@ -1009,7 +1088,7 @@
|
||||
if (!camLive) return;
|
||||
shutterLock = true;
|
||||
capturing = true;
|
||||
if (isManualMode()) captureHaptic(navigator); // Manual shutter has no readiness callback.
|
||||
resetAngleHaptic(); // Only invalid angles vibrate, never the shutter itself.
|
||||
clearTimeout(nudgeTimer);
|
||||
hideNudge();
|
||||
var seq = camSeq;
|
||||
@@ -1481,14 +1560,17 @@
|
||||
|
||||
/* ─── 相机可用后加载检测模型(加载完成前快门即手动模式) ─── */
|
||||
function beginDetect() {
|
||||
var token = camSeqCam;
|
||||
if (IS_MOCK || camFallback) { resetDetectState(); return; }
|
||||
detectDisabled = false; // 上次模型加载失败允许重试(modelsPromise 失败时已清空,loadModels 会重新拉取)
|
||||
resetDetectState();
|
||||
if (!modelsOk) showCamStatus('检测引擎加载中…');
|
||||
loadModels().then(function () {
|
||||
if (token !== camSeqCam || !camLive) return;
|
||||
$('#cam-status').classList.remove('show');
|
||||
maybeStartDetect();
|
||||
}).catch(function () {
|
||||
if (token !== camSeqCam || !camLive) return;
|
||||
$('#cam-status').classList.remove('show');
|
||||
detectDisabled = true; // 仅禁用当次;下次进拍照页 beginDetect 复位后重试
|
||||
resetDetectState();
|
||||
@@ -1619,10 +1701,12 @@
|
||||
},
|
||||
'flip': function () { // 切换前后镜头
|
||||
if (camFallback) { toast('系统相机模式下请在系统相机内切换'); return; }
|
||||
if (!camStream) { toast('相机启动中,请稍候'); return; }
|
||||
if (camBusy || !camLive) { toast('相机启动中,请稍候'); return; }
|
||||
if (shutterLock || capturing) { toast('拍摄中,请稍候'); return; }
|
||||
var previousFacing = camFacing;
|
||||
freezeCamera();
|
||||
camFacing = camFacing === 'user' ? 'environment' : 'user';
|
||||
camMirrored = camFacing === 'user';
|
||||
startCamera(); // 复用启停与预热(camSeqCam 防竞态)
|
||||
startCamera(previousFacing, true);
|
||||
},
|
||||
'history': function () { openHistory(); },
|
||||
'history-retry': function () { renderHistoryList(); },
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
yawDrift: 8, pitchDrift: 8, centerDrift: 0.055, sizeDrift: 0.065 });
|
||||
function finite(v) { return typeof v === 'number' && isFinite(v); }
|
||||
function evaluate(d, options) {
|
||||
var failed = function(hint) { return { ok: false, hint: hint }; };
|
||||
var failed = function(hint, reason) { return { ok: false, hint: hint, reason: reason || 'quality' }; };
|
||||
if (!d) return failed('未检测到人脸');
|
||||
if (options.count !== 1) return failed('请保持单人出镜');
|
||||
var box = d.detection.box, w = options.width, h = options.height;
|
||||
@@ -31,7 +31,7 @@
|
||||
var target = options.target;
|
||||
if (options.ignoreDirection && target !== 'front') target = pose.yaw < 0 ? 'left' : 'right';
|
||||
var check = HeadPose.check(d.landmarks.positions, target, options.mirrored);
|
||||
if (!check.ok) return failed(check.hint);
|
||||
if (!check.ok) return failed(check.hint, finite(check.yaw) ? 'angle' : 'quality');
|
||||
if (!finite(options.luma)) return failed('请保持面部清晰');
|
||||
if (options.luma < config.dark) return failed('请到光线充足处');
|
||||
if (options.luma > config.bright) return failed('光线过强,请避开强光');
|
||||
|
||||
Reference in New Issue
Block a user