fix(h5): optimize camera flipping and angle haptics, refresh splash branding

This commit is contained in:
xsl
2026-10-13 17:57:55 +08:00
parent e71368db6d
commit 7381306139
19 changed files with 2125 additions and 1282 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

+5
View File
@@ -220,6 +220,11 @@ button { font-family: inherit; border: 0; background: none; cursor: pointer; col
#cam-video.mirror { transform: scaleX(-1); } /* 前置镜头镜像预览 */
#s-cam.cam-live #cam-video { display: block; }
#s-cam.cam-live .cam-bg { display: none; }
#cam-frozen { position:absolute; inset:0; width:100%; height:100%; object-fit:cover; display:none; z-index:0; pointer-events:none; }
#cam-frozen.mirror { transform:scaleX(-1); }
#s-cam.cam-switching #cam-frozen { display:block; }
#s-cam.cam-switching .cam-bg { display:none; }
.cam-flip:disabled { opacity:.5; }
/* 纵向定位按 WebView 口径:Figma 帧 812 高扣掉顶部 86px 微信导航栏 = 726 高视口 */
.cam-step {
position: absolute; left: 0; right: 0; top: 7.1%;
+1 -1
View File
@@ -95,7 +95,7 @@
}
.guide-description { font-size: 12px; line-height: 1.6; margin-bottom: calc(35 * var(--u)); }
.cam-feature-lines { fill: none; stroke: #fff; stroke-width: 2.5; stroke-dasharray: 5 5; filter: drop-shadow(0 1px 1px #000); }
#s-cam:not(.cam-live) .cam-feature-lines { stroke: #111; filter: none; }
#s-cam:not(.cam-live):not(.cam-switching) .cam-feature-lines { stroke: #111; filter: none; }
.font-credit { display: block; text-align: center; color: #888; font-size: 10px; margin: 24px auto 0; }
.account-status { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 14px; border-radius: 8px; font-size: 12px; }
.account-status span::before { content: ''; display: inline-block; width: 7px; height: 7px; margin-right: 7px; border-radius: 50%; background: #b7bac0; }
+7
View File
@@ -9,6 +9,13 @@
top: max(6.838833%, calc(env(safe-area-inset-top) + 48px));
}
#s-splash .splash-slogan { top: max(6.838833%, calc(env(safe-area-inset-top) + 48px)); }
/* Retain the PSD's two lower slogan lines; the updated brand uses regular type. */
.splash-slogan-updated .splash-slogan-copy { width:100%; height:100%; clip-path:inset(32% 0 0); }
.splash-slogan-brand { position:absolute; top:0; left:50%; transform:translateX(-50%); font-family:Arial,sans-serif; font-size:23px; font-weight:400; line-height:1; white-space:nowrap; }
#s-splash [data-action="open-privacy"],
#s-splash [data-action="open-privacy"]:focus,
#s-splash [data-action="open-privacy"]:active { border:0; outline:none; box-shadow:none; -webkit-appearance:none; appearance:none; }
#s-splash [data-action="open-privacy"]:focus-visible { filter:brightness(.94); }
.splash-bottom { bottom: max(5.882353%, env(safe-area-inset-bottom)); gap: calc(7.291667 * var(--u)); padding-bottom: 0; }
.brand-row { margin-bottom: calc(10.416667 * var(--u)); gap: 0; }
.brand-lockup { width: 179.513889px; height: 20.486111px; }
+5 -2
View File
@@ -43,7 +43,10 @@
<section class="screen" id="s-splash">
<div class="splash-bg"></div>
<div class="account-status account-status-home"><span data-auth-status></span><button type="button" class="account-logout" data-action="logout-wechat" hidden>退出登录</button></div>
<img class="splash-slogan" src="assets/img/ui-20261012/splash-slogan.png" width="718" height="217" loading="lazy" alt="ONE ME,FROM THE INSIDE OUT;以我为始 自有表现">
<div class="splash-slogan splash-slogan-updated" role="img" aria-label="1'me,FROM THE INSIDE OUT;以我为始 自有表现">
<img class="splash-slogan-copy" src="assets/img/ui-20261012/splash-slogan.png" width="718" height="217" loading="lazy" alt="">
<span class="splash-slogan-brand" aria-hidden="true">1'me</span>
</div>
<div class="splash-bottom">
<div class="brand-row">
<img src="assets/img/ui-20261012/brand-lockup.png" width="517" height="59" loading="lazy" alt="1'me 肌肤状态检测" class="brand-lockup">
@@ -159,7 +162,7 @@
<img src="assets/img/ui-20261009/icon-shutter.png" alt="快门">
</button>
<button class="cam-side cam-flip" data-action="flip" aria-label="切换">
<img src="assets/img/ui-20261009/icon-switch-camera.png" alt="切换">
<img src="assets/img/ui-20261009/icon-switch-camera.png?v=20261013" alt="切换">
</button>
</div>
<div class="cam-fill" id="cam-fill"></div>
+119 -35
View File
@@ -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(); },
+2 -2
View File
@@ -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('光线过强,请避开强光');
+121
View File
@@ -0,0 +1,121 @@
// Canvas cameras and intercepted auth; no personal images, writes or paid API.
import assert from 'node:assert/strict';
import fs from 'node:fs';
import { execSync } from 'node:child_process';
import { fileURLToPath, pathToFileURL } from 'node:url';
const { chromium } = await import(pathToFileURL(execSync('npm root -g', { encoding: 'utf8' }).trim() + '/playwright/index.mjs').href);
const browser = await chromium.launch({ args: ['--autoplay-policy=no-user-gesture-required'] });
try {
const page = await browser.newPage({ viewport: { width: 375, height: 726 } });
const errors = [];
page.on('pageerror', e => errors.push(e.message));
await page.route('**/api/**', route => {
assert.equal(route.request().method(), 'GET', 'no account/report writes');
return route.fulfill({ json: { ok: true, user: { id: 'camera-fixture', kind: 'browser' } } });
});
const source = fs.readFileSync(fileURLToPath(new URL('../js/app.js', import.meta.url)), 'utf8');
await page.route('**/js/app.js*', route => route.fulfill({ contentType: 'text/javascript', body: source.replace(/\}\)\(\);\s*$/, `
window.__cameraTest = {
enter: function() { showScreen('s-cam'); startCamera(); renderCamStep(); },
stop: stopCamera,
capture: function(value) { shutterLock = value; },
startDetection: function() { modelsOk = true; detectDisabled = false; IS_MOCK = false; maybeStartDetect(); },
state: function() { return { live: camLive, busy: camBusy, facing: camFacing, fallback: camFallback }; }
};
})();`) }));
await page.addInitScript(() => {
const nativeTimeout = window.setTimeout.bind(window);
window.setTimeout = (fn, ms, ...args) => nativeTimeout(fn, window.__shortCameraTimeout && ms === 12000 ? 200 : ms, ...args);
window.__calls = []; window.__delay = 0; window.__fail = false; window.__ignoreFacing = false;
window.__streams = []; window.__frameAt = 0;
navigator.mediaDevices.getUserMedia = async constraints => {
const facing = constraints.video.facingMode.ideal;
window.__calls.push(facing);
const fail = window.__fail, delay = window.__delay;
window.__fail = false;
await new Promise(r => setTimeout(r, delay));
if (fail) throw new Error('fixture camera unavailable');
const canvas = document.createElement('canvas'); canvas.width = 640; canvas.height = 480;
const ctx = canvas.getContext('2d');
ctx.fillStyle = facing === 'user' ? '#316a94' : '#d18e48'; ctx.fillRect(0, 0, 640, 480);
const stream = canvas.captureStream(20), track = stream.getVideoTracks()[0];
const settings = track.getSettings.bind(track);
track.getSettings = () => ({ ...settings(), facingMode: window.__ignoreFacing ? 'user' : facing });
const timer = setInterval(() => ctx.fillRect(0, 0, 640, 480), 40);
const stop = track.stop.bind(track); track.stop = () => { clearInterval(timer); stop(); };
window.__streams.push(stream);
return stream;
};
document.addEventListener('DOMContentLoaded', () => {
document.querySelector('#cam-video').addEventListener('loadeddata', () => { window.__frameAt = performance.now(); });
});
});
await page.goto((process.env.H5_BASE_URL || 'http://127.0.0.1:8080/') + '?mock=1');
await page.evaluate(() => window.__cameraTest.enter());
const live = () => page.waitForFunction(() => window.__cameraTest.state().live);
await live();
for (const delay of [0, 1000]) {
await page.evaluate(delay => { window.__delay = delay; document.querySelector('.cam-flip').click(); }, delay);
assert(await page.locator('#cam-frozen').isVisible(), 'frozen preview during switching');
const count = await page.evaluate(() => window.__calls.length);
await page.evaluate(() => document.querySelector('.cam-flip').click());
assert.equal(await page.evaluate(() => window.__calls.length), count, 'rapid click ignored');
await live();
const extra = await page.evaluate(() => performance.now() - window.__frameAt);
assert(extra < 500, `first-frame to visible latency ${extra}ms, no fixed 900ms wait`);
assert(!(await page.locator('#cam-frozen').isVisible()));
console.log('flip delay', delay, 'first-frame to visible', Math.round(extra), 'ms');
}
const before = await page.evaluate(() => window.__cameraTest.state().facing);
await page.evaluate(() => { window.__delay = 0; window.__fail = true; document.querySelector('.cam-flip').click(); });
await live();
assert.equal(await page.evaluate(() => window.__cameraTest.state().facing), before, 'failed flip restores camera');
assert.equal(await page.evaluate(() => window.__cameraTest.state().fallback), false);
await page.evaluate(() => { window.__ignoreFacing = true; document.querySelector('.cam-flip').click(); });
await live();
assert.equal(await page.evaluate(() => window.__cameraTest.state().facing), before, 'ignored facing is detected/restored');
await page.evaluate(() => {
window.__ignoreFacing = false; window.__shortCameraTimeout = true; window.__delay = 600;
document.querySelector('.cam-flip').click();
window.__delay = 0; // restoration succeeds while the original request is still pending
});
await live();
assert.equal(await page.evaluate(() => window.__cameraTest.state().facing), before, 'timeout restores original camera');
await page.waitForTimeout(700);
assert(await page.evaluate(() => window.__streams.slice(0, -2).every(s => s.getTracks().every(t => t.readyState === 'ended'))));
await page.evaluate(() => { window.__shortCameraTimeout = false; });
const count = await page.evaluate(() => window.__calls.length);
await page.evaluate(() => { window.__cameraTest.capture(true); document.querySelector('.cam-flip').click(); window.__cameraTest.capture(false); });
assert.equal(await page.evaluate(() => window.__calls.length), count, 'capture blocks flip');
// Pending inference from the old camera must neither update UI nor fork loops.
await page.evaluate(() => {
window.__detectCalls = 0; window.__concurrent = 0; window.__maxConcurrent = 0;
window.faceapi = {
nets: { tinyFaceDetector: { loadFromUri: () => Promise.resolve() }, faceLandmark68Net: { loadFromUri: () => Promise.resolve() } },
TinyFaceDetectorOptions: function() {},
detectAllFaces: function() { return { withFaceLandmarks: function() {
window.__detectCalls++; window.__concurrent++;
window.__maxConcurrent = Math.max(window.__maxConcurrent, window.__concurrent);
return new Promise(resolve => { window.__resolveDetect = () => { window.__concurrent--; resolve([]); }; });
} }; }
};
window.__cameraTest.startDetection();
});
await page.waitForFunction(() => window.__detectCalls === 1);
await page.evaluate(() => { window.__ignoreFacing = false; document.querySelector('.cam-flip').click(); });
await live();
await page.waitForTimeout(600);
assert.equal(await page.evaluate(() => window.__detectCalls), 1, 'do not overlap old inference');
await page.evaluate(() => window.__resolveDetect());
await page.waitForFunction(() => window.__detectCalls === 2);
await page.evaluate(() => window.__resolveDetect());
await page.waitForFunction(() => window.__detectCalls === 3);
assert.equal(await page.evaluate(() => window.__maxConcurrent), 1, 'single detection loop');
await page.evaluate(() => { window.__delay = 600; document.querySelector('.cam-flip').click(); window.__cameraTest.stop(); });
await page.waitForTimeout(900);
assert.equal(await page.evaluate(() => window.__cameraTest.state().live), false, 'late stream cannot revive stopped camera');
assert(await page.evaluate(() => window.__streams.every(s => s.getTracks().every(t => t.readyState === 'ended'))), 'all streams released');
assert.equal(await page.evaluate(() => document.querySelector('#cam-frozen').width), 0, 'frozen face released');
assert.deepEqual(errors, []);
console.log('PASS camera flip: first frame, frozen preview, rapid clicks, recovery/timeout, capture lock, stale inference, cleanup');
} finally { await browser.close(); }
+6
View File
@@ -31,6 +31,7 @@ assert(!check(null).ok); assert(!check(face(), { count: 2 }).ok);
for (const yaw of [-14, 14]) assert(check(face(yaw)).ok, 'former strict front limit now passes');
for (const yaw of [-16, 16, -45, 45]) {
assert(!check(face(yaw)).ok);
assert.equal(check(face(yaw)).reason, 'angle');
assert.equal(check(face(yaw)).hint, '请正对屏幕', 'front correction must not ask for a side view before capture');
assert.equal(check(face(yaw), { mirrored: true }).hint, '请正对屏幕');
}
@@ -49,6 +50,11 @@ assert(check(face(-45), { target: 'right', mirrored: true }).ok);
assert(check(face(-45), { target: 'right', ignoreDirection: true }).ok);
assert(!check(face(25), { target: 'left', ignoreDirection: true }).ok);
for (const luma of [null, 50, 240, NaN]) assert(!check(face(), { luma }).ok);
assert.equal(check(face(), { luma: 50 }).reason, 'quality', 'lighting must not trigger angle vibration');
assert.equal(check(null).reason, 'quality');
assert.equal(check(face(), { count: 2 }).reason, 'quality');
const brokenLandmarks = face(); brokenLandmarks.landmarks.positions = [];
assert.equal(check(brokenLandmarks).reason, 'quality', 'unreadable pose is not confirmed wrong angle');
for (const luma of [70, 230]) assert(check(face(), { luma }).ok);
assert(check(face(), { offsetX: .15 }).ok);
assert(!check(face(), { offsetX: .17 }).ok);
+42 -10
View File
@@ -36,8 +36,8 @@ assert.equal(JSON.stringify(geometry.window.CameraGeometry), original);
for (const value of [null, '', 'M--Z', 'M1,2L1,2Z', 'M0,0L0,0L0,0Z', 'M0,0Q1,2 3,4Z']) assert.equal(context.smoothCameraOutline(value), value);
for (const nav of [null, {}, { vibrate: false }, { vibrate: () => false }, { vibrate: () => { throw Error('denied'); } }]) assert.equal(context.captureHaptic(nav), false);
let pattern;
assert(context.captureHaptic({ vibrate: p => { pattern = Array.from(p); return true; } }));
assert.deepEqual(pattern, [70, 40, 70]);
assert(context.captureHaptic({ vibrate: p => { pattern = p; return true; } }));
assert.equal(pattern, 30);
// Exercise the actual readiness state machine; only camera/pose inputs and clock
// are stubs; the relaxed shared hold policy now requires 450ms/2 frames.
@@ -47,7 +47,8 @@ const state = {
faceOffsetX: () => 0, CENTER_TOL: .18, CAM_STEPS: [{ target: 'front' }], camStep: 0,
HeadPose: { check: () => ({ ok: true }) }, sideYawFlip: false, faceLuma: () => 100,
LUMA_DARK: 65, LUMA_BRIGHT: 235, window: {}, setMask: () => {}, setHint: () => {},
okSince: 0, readyHapticAt: 0, OK_HOLD_MS: 450, Date: { now: () => now },
okSince: 0, OK_HOLD_MS: 450, Date: { now: () => now },
shutterLock: false, capturing: false, camBusy: false, camLive: true, currentScreen: 's-cam', document: { hidden: false },
holdState: null, CaptureQuality: {
evaluate: d => ({ ok: d.detection.box.height >= 100, hint: '保持不动', observation: { yaw: 0, pitch: 0, center_x: .5, center_y: .5, ratio: .5 } }),
hold: require('../js/capture-quality').hold
@@ -56,13 +57,13 @@ const state = {
};
vm.runInNewContext(extract('camera-haptic-pure') + source.slice(source.indexOf(' function onDetect(d)'), source.indexOf(' /* ─── 拍照流程')), state);
const face = { detection: { box: { height: 250 } }, landmarks: { positions: [] } };
state.onDetect(face); assert.equal(buzzes, 1); assert.equal(captures, 0);
now += 200; state.onDetect(face); assert.equal(buzzes, 1); assert.equal(captures, 0);
now += 250; state.onDetect(face); assert.equal(buzzes, 1); assert.equal(captures, 1);
state.onDetect(face); assert.equal(buzzes, 0); assert.equal(captures, 0);
now += 200; state.onDetect(face); assert.equal(buzzes, 0); assert.equal(captures, 0);
now += 250; state.onDetect(face); assert.equal(buzzes, 0); assert.equal(captures, 1);
now += 100; state.onDetect({ ...face, detection: { box: { height: 20 } } });
now += 100; state.onDetect(face); assert.equal(buzzes, 1, 'jitter must not repeatedly buzz');
now += 100; state.onDetect(face); assert.equal(buzzes, 0, 'good angles must not buzz');
now += 2100; state.onDetect({ ...face, detection: { box: { height: 20 } } });
state.onDetect(face); assert.equal(buzzes, 2);
state.onDetect(face); assert.equal(buzzes, 0);
state.navigator.vibrate = () => { throw Error('blocked'); };
now += 300; state.onDetect(face);
now += 150; state.onDetect(face); assert.equal(captures, 2, 'vibration failure must not prevent capture');
@@ -74,8 +75,39 @@ for (const manual of [true, false]) {
$: () => ({ classList: { add: () => {} } }), setTimeout: (_, ms) => scheduled.push(ms) };
vm.runInNewContext(extract('camera-haptic-pure') + source.slice(source.indexOf(' function doCapture()'), source.indexOf(' // 三张拍完:按视角重排')), shutter);
shutter.doCapture();
assert.equal(manualBuzzes, manual ? 1 : 0, 'auto must not double buzz; manual must retain its cue');
assert.equal(manualBuzzes, 0, 'neither auto nor manual shutter should buzz');
assert(shutter.shutterLock && shutter.capturing);
assert.deepEqual(scheduled, [300], 'blocked haptic must still schedule camera capture');
}
console.log('PASS: three bounded rounded contours; haptic safety, pre-capture cue, jitter throttling; relaxed 450ms/2-frame hold');
// Drive actual onDetect with angle-only failures, not UI hint string matching.
const events = [];
state.navigator.vibrate = p => { events.push({ p, at: now }); return true; };
state.CaptureQuality.evaluate = () => ({ ok: false, reason: 'angle', hint: '请正对屏幕' });
state.onDetect(face);
now += 999; state.onDetect(face); assert.equal(events.length, 0);
now += 1; state.onDetect(face); assert.equal(events.length, 1); assert.equal(events[0].p, 30);
for (let i = 0; i < 3; i++) { now += 1000; state.onDetect(face); }
assert.equal(events.length, 1, 'no repeat before 4 seconds');
now += 1000; state.onDetect(face); assert.equal(events.length, 2);
state.onDetect(null); assert.equal(events.at(-1).p, 0, 'missing face cancels vibration immediately');
const positives = () => events.filter(e => e.p > 0).length;
state.onDetect(face); now += 999; state.onDetect(face); assert.equal(positives(), 2, 'new continuous interval required');
state.CaptureQuality.evaluate = () => ({ ok: false, reason: 'quality', hint: '请靠近一点' });
now += 10000; state.onDetect(face); assert.equal(positives(), 2, 'distance/lighting failure does not buzz');
state.CaptureQuality.evaluate = () => ({ ok: false, reason: 'angle', hint: '请正对屏幕' });
state.onDetect(face); now += 1000; state.onDetect(face); assert.equal(positives(), 3);
state.CaptureQuality.evaluate = () => ({ ok: false, reason: 'quality', hint: '请到光线充足处' });
state.onDetect(face); assert.equal(events.at(-1).p, 0, 'valid angle cancels even when lighting fails');
for (const [key, value] of [['camBusy', true], ['shutterLock', true], ['capturing', true], ['camLive', false], ['currentScreen', 's-report']]) {
const previous = state[key]; state[key] = value;
state.CaptureQuality.evaluate = () => ({ ok: false, reason: 'angle' });
state.onDetect(face); now += 5000; state.onDetect(face);
assert.equal(positives(), 3, key + ' blocks reminders'); state[key] = previous;
}
state.document.hidden = true; state.onDetect(face); now += 5000; state.onDetect(face); assert.equal(positives(), 3);
state.document.hidden = false;
for (const nav of [{}, { vibrate: () => false }, { vibrate: () => { throw Error('blocked'); } }]) {
state.navigator = nav; state.resetAngleHaptic();
state.onDetect(face); now += 1000; assert.doesNotThrow(() => state.onDetect(face));
}
console.log('PASS: rounded contours; 1s invalid-angle cue, 4s throttle, cancellation/lifecycle, unsupported safety; unchanged 450ms/2-frame capture hold');
+35
View File
@@ -0,0 +1,35 @@
// Read-only fixture: no accounts/reports created.
import assert from 'node:assert/strict';
import { execSync } from 'node:child_process';
import { pathToFileURL } from 'node:url';
const { chromium } = await import(pathToFileURL(execSync('npm root -g', { encoding:'utf8' }).trim() + '/playwright/index.mjs').href);
const browser = await chromium.launch();
try {
const page = await browser.newPage({ viewport:{ width:375, height:726 } });
await page.route('**/api/**', route => {
assert.equal(route.request().method(), 'GET');
return route.fulfill({ json:{ ok:true, user:null } });
});
await page.goto(process.env.H5_BASE_URL || 'http://127.0.0.1:8080/');
await page.click('[data-action="guest-experience"]');
await page.waitForSelector('#s-splash.active');
const brand = page.locator('#s-splash .splash-slogan');
assert.match(await brand.getAttribute('aria-label'), /^1'me/);
await page.waitForFunction(() => [...document.querySelectorAll('#s-splash .splash-slogan img')].every(i => i.complete && i.naturalWidth));
assert.equal(await page.locator('#s-splash .splash-slogan-brand').isVisible(), true);
assert.equal(await page.locator('#s-splash .splash-slogan-brand').textContent(), "1'me");
assert.equal(await page.locator('#s-splash .splash-slogan-brand').evaluate(el => getComputedStyle(el).fontWeight), '400');
const button = page.locator('#s-splash [data-action="open-privacy"]');
await button.focus();
const style = await button.evaluate(el => {
const s = getComputedStyle(el);
return { border:s.borderTopWidth, outline:s.outlineStyle, shadow:s.boxShadow, background:s.backgroundColor };
});
assert.deepEqual(style, { border:'0px', outline:'none', shadow:'none', background:'rgb(245, 240, 120)' });
await button.evaluate(el => el.blur());
await page.waitForTimeout(400); // finish the entry screen fade before the screenshot
await page.screenshot({ path:'/tmp/juzi-splash-brand-20261013.png' });
await button.click();
await page.waitForSelector('#p-privacy.show');
console.log('PASS splash brand, borderless focused start button, yellow fill, privacy entry');
} finally { await browser.close(); }