fix(h5): show captured face behind report scan animation

This commit is contained in:
xsl
2026-10-12 21:21:49 +08:00
parent ce6dbc20d4
commit 69dc9962c0
14 changed files with 1443 additions and 878 deletions
+1 -1
View File
@@ -71,7 +71,7 @@ node server.js # 默认 8080,可用 PORT=xxx 修改
- **白屏补光**:全屏纯白覆盖层瞬间点亮,~300ms 后抓全分辨率帧(原始视角 + 最长边 1280 JPEG),保持 ~300ms 再淡出,同步 `navigator.vibrate(30)`。
- **快门只在无法实时识别时出现**:系统相机降级、face-api/模型加载失败、`?mock=1` 调试。文件照片共用同一套本地单人/距离/完整入镜/角度/光线校验,不合格停在当前步;系统照片可能已镜像,侧脸不判断左右,并标记 `direction_checked:false`。本地模型加载中最多等 8 秒;确实不可用时保留原有降级通路,但后台明确记录 `unchecked/model-unavailable`,不冒充检查通过。服务端质量结果仅记录,不新增重拍阻断。
- **报告等待与质量留痕**:原 PSD 画面增加约 1 秒上下往返扫描;成功跳转等待至少一次实际动画循环,错误/取消立即恢复。拍摄质量备注不在用户报告展示,后台列表、详情和 CSV 保留侧脸质量状态与前端参数版本。前端诊断为客户端自报,仅供校准,不作为服务端质量真值。
- **报告等待与质量留痕**:本次正脸实拍覆盖 PSD 白色人脸占位,保持与拍摄页相同的等比铺满和前置预览镜像(仅显示,不改送检 JPEG);保留 PSD 框、文案、装饰图标与约 1 秒上下往返扫描。成功跳转等待至少一次实际动画循环,错误/取消立即恢复并清理等待页照片引用。PSD 素材加载失败仍显示照片、文字和取消入口。拍摄质量备注不在用户报告展示,后台列表、详情和 CSV 保留侧脸质量状态与前端参数版本。前端诊断为客户端自报,仅供校准,不作为服务端质量真值。
- **相册选图**:相机页「相册」按钮可选当前步的图片,与降级拍照同管线(压 1280 → 校验 → 推进)。
- **语音播报**:步骤引导/姿态提示/拍摄完成/分析中/失败,文案与文件名契约见 `../docs/语音播报内容设计文档_V1.0.md`。新增的提示(居中、抬头/低头、光线)暂无音频,清单缺失的条目静默跳过,只显示文字。
- **无进展提示**:单步 12s 仍未拍到,底部浮出「识别不到?请正对镜头,并到光线充足处」(不阻塞流程)。
+11 -3
View File
@@ -75,12 +75,20 @@
/* Fourth feedback adds a fast one-second round trip to original PSD pixels.
The stage/mask share one contain transform, including short/landscape screens. */
#p-analyzing { background: #000; overflow: hidden; }
.scan-stage { position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); width: min(100vw, 480px, calc(100dvh * 1080 / 2091)); height: min(100dvh, calc(min(100vw, 480px) * 2091 / 1080)); }
.scan-stage { position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); width: min(100vw, 480px, calc(100dvh * 1080 / 2091)); height: min(100dvh, calc(min(100vw, 480px) * 2091 / 1080)); overflow: hidden; }
@supports not (height: 100dvh) { .scan-stage { width: min(100vw, 480px, calc(100vh * 1080 / 2091)); height: min(100vh, calc(min(100vw, 480px) * 2091 / 1080)); } }
.scan-artwork { width: 100%; height: 100%; object-fit: contain; }
.scan-face { position: absolute; inset: 0; -webkit-mask-size: 100% 100%; mask-size: 100% 100%; -webkit-mask-repeat: no-repeat; mask-repeat: no-repeat; overflow: hidden; }
.scan-face { position: absolute; inset: 0; background: #111; -webkit-mask-size: 100% 100%; mask-size: 100% 100%; -webkit-mask-repeat: no-repeat; mask-repeat: no-repeat; overflow: hidden; }
/* Same centered cover crop as the camera; never stretch the captured JPEG.
The PSD mask clips the real face, covering the original white placeholder. */
.scan-photo { position: absolute; left: 50%; top: 50%; width: min(100vw, 480px); height: 100vh; height: 100dvh; max-width: none; object-fit: cover; transform: translate(-50%, -50%); }
.scan-photo.mirror { transform: translate(-50%, -50%) scaleX(-1); }
.scan-photo:not([src]) { visibility: hidden; }
.scan-beam { position: absolute; left: 0; right: 0; top: 20%; height: 18%; background: linear-gradient(to bottom, #F5F078 0%, #F5F078 35%, rgba(245,240,120,0) 100%); }
#p-analyzing:not(.scan-artwork-ready) .scan-stage { visibility: hidden; }
#p-analyzing:not(.scan-artwork-ready) .scan-artwork { visibility: hidden; }
#p-analyzing:not(.scan-artwork-ready) .scan-face { -webkit-mask-image: none !important; mask-image: none !important; }
#p-analyzing:not(.scan-artwork-ready) .scan-photo { opacity: .6; }
#p-analyzing:not(.scan-artwork-ready) .scan-beam { visibility: hidden; }
.scan-running .scan-beam { animation: report-scan var(--scan-cycle, 1000ms) ease-in-out infinite; }
@keyframes report-scan { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(166%); } }
.scan-running:not(.scan-artwork-ready) .scan-status { animation: report-scan-fallback var(--scan-cycle, 1000ms) ease-in-out infinite; }
+4 -1
View File
@@ -457,7 +457,10 @@
Not camera controls: no action attributes, focus targets or file inputs. -->
<div class="scan-stage" aria-hidden="true">
<img id="scan-artwork" class="scan-artwork" data-src="assets/img/ui-20261012/scan-base.webp" width="1080" height="2091" alt="" draggable="false">
<div id="scan-face" class="scan-face" data-mask="assets/img/ui-20261012/scan-face-mask.png"><i class="scan-beam"></i></div>
<div id="scan-face" class="scan-face" data-mask="assets/img/ui-20261012/scan-face-mask.png">
<img id="scan-photo" class="scan-photo" alt="" draggable="false">
<i class="scan-beam"></i>
</div>
</div>
<p class="scan-status" role="status">检测中…</p>
<button class="scan-cancel" type="button" data-action="cancel-analysis">取消</button>
+31 -6
View File
@@ -143,6 +143,22 @@
/* ─── 弹窗管理 ─── */
var SCAN_CYCLE_MS = 1000, scanCycle = null, scanMask = null;
function setScanPhoto(source, mirrored) {
var image = $('#scan-photo');
image.classList.toggle('mirror', !!mirrored);
if (image.getAttribute('src') === source) return;
image.onload = function () {
// A delayed JPEG decode must still get one complete visible scan cycle.
if ($('#p-analyzing').classList.contains('show')) restartScanCycle();
};
image.onerror = function () { image.removeAttribute('src'); };
image.src = source;
}
function clearScanPhoto() {
var image = $('#scan-photo');
image.onload = image.onerror = null;
image.removeAttribute('src'); image.classList.remove('mirror');
}
function restartScanCycle() {
if (!scanCycle || scanCycle.done) return;
var overlay = $('#p-analyzing'), cycle = scanCycle;
@@ -204,7 +220,10 @@
$('#' + id).classList.add('show');
if (id === 'p-analyzing') { startScanCycle(); $('.scan-cancel').focus({ preventScroll: true }); }
}
function closeOverlay(id) { $('#' + id).classList.remove('show'); if (id === 'p-analyzing') stopScanCycle(); }
function closeOverlay(id) {
$('#' + id).classList.remove('show');
if (id === 'p-analyzing') { stopScanCycle(); clearScanPhoto(); }
}
// 点遮罩空白处关闭(与各自关闭按钮同行为:隐私→回启动页、拍照引导→留在档案页)
// 只响应遮罩本身(拍照引导另含底图层),不误伤弹窗卡片内交互
@@ -442,7 +461,7 @@
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 photos = []; // 三连拍(dataURL),拍摄中与 CAM_STEPS 同序;送检前经 photosByView 重排为 [正脸, 左脸, 右脸]
var photos = [], scanPhotoMirrored = false; // 三连拍(dataURL),拍摄中与 CAM_STEPS 同序;送检前经 photosByView 重排为 [正脸, 左脸, 右脸]
var captureDiagnostics = [], holdState = null;
// 约束逐级放宽:facingMode 裸值在部分 Android 抛 OverconstrainedError,先 ideal 再退 video:true,最后才降级
@@ -568,7 +587,7 @@
hideCamStatus();
shutterLock = false;
if (err) { toast(err); voice.play(VOICE.fail, { cut: true }); return; } // 不合格:停在本步重拍
onPhotoCaptured(url);
onPhotoCaptured(url, false); // Files already have their own camera orientation.
});
};
img.onerror = function () { toast('照片读取失败,请重新选择'); };
@@ -1009,9 +1028,13 @@
var shutterLock = false; // 快门防连点锁:流程进行中直接忽略
// 照片就绪(已通过校验)后推进流程:存图→「拍摄完成」→300ms 过渡→下一步;三步严格按顺序,本步合格才进下一步
function onPhotoCaptured(dataUrl) {
function onPhotoCaptured(dataUrl, mirrored) {
shutterLock = true; // 推进窗口上锁(停顿期间不检测、不接受快门),直到下一步/送检
photos[camStep] = dataUrl;
if (camStep === 0) {
scanPhotoMirrored = !!mirrored;
setScanPhoto(dataUrl, scanPhotoMirrored); // Decode the front photo before the waiting screen opens.
}
voice.play(VOICE.shot, { cut: true }); // 「拍摄完成」:已拍下,本步未播完的引导作废
var seq = camSeq, hasNext = camStep < CAM_STEPS.length - 1;
if (hasNext) {
@@ -1049,7 +1072,7 @@
var fill = $('#cam-fill');
fill.classList.add('on'); // .on 无过渡,瞬间点亮
setTimeout(function () {
var shot = captureFromVideo();
var shot = captureFromVideo(), mirrored = effMirror();
setTimeout(function () {
fill.classList.remove('on'); // 移除 .on 恢复过渡,淡出
capturing = false;
@@ -1062,7 +1085,7 @@
shutterLock = false; holdState = null; okSince = 0;
setHint(error, false); armNudge(); return;
}
onPhotoCaptured(shot);
onPhotoCaptured(shot, mirrored);
});
}, 300);
}, 300);
@@ -1072,6 +1095,7 @@
function submitPhotos(seq) {
camStep = 0;
photos = photosByView(photos);
setScanPhoto(photos[0], scanPhotoMirrored);
openOverlay('p-analyzing');
voice.play(VOICE.analyzing); // 「正在生成您的肌肤报告」
// 档案与检测一起上报(后台记录用):年龄非法值服务端按 null 处理
@@ -1629,6 +1653,7 @@
camSeq++; // 使旧的快门/分析回调失效
camStep = 0;
photos = []; // 新一轮三连拍(从 startCamera 挪出:后台恢复续拍时保留已拍)
scanPhotoMirrored = false; clearScanPhoto();
captureDiagnostics = [];
shutterLock = false; // 重新进入流程,保险解锁
showScreen('s-cam', 'push');
+3
View File
@@ -97,7 +97,10 @@ try {
const sent = page.waitForRequest(r => r.url().endsWith('/api/skin-analyze'));
await page.evaluate(() => window.__cameraFixture.set('C3_-135.jpg'));
const body = (await sent).postDataJSON();
assert.equal(await page.locator('#scan-photo').getAttribute('src'), body.front_image, 'real live waiting uses the captured front photo');
assert.equal(await page.locator('#scan-photo.mirror').count(), 1, 'front camera waiting keeps the preview mirror without changing the sent JPEG');
await page.waitForSelector('#s-report.active');
assert.equal(await page.locator('#scan-photo').getAttribute('src'), null, 'waiting photo reference is cleared after success');
for (const key of ['front', 'left', 'right']) {
const row = body.capture_diagnostics.views[key];
assert.equal(row.mode, 'live'); assert.equal(row.direction_checked, true);
+4 -2
View File
@@ -120,8 +120,10 @@ try {
const context = canvas.getContext('2d'); context.drawImage(image, 0, 0);
return [[540, 720], [540, 1200]].map(([x, y]) => Array.from(context.getImageData(x, y, 1, 1).data));
});
assert(scanPixels[0].slice(0, 3).every(value => value > 245), 'visible PSD face must not be lost to clipping');
assert(scanPixels[1].slice(0, 3).every(value => value > 245), 'base face stays white; yellow scan is now a separate animated layer');
assert(scanPixels[0].slice(0, 3).every(value => value > 245), 'original PSD bitmap is unchanged');
assert(scanPixels[1].slice(0, 3).every(value => value > 245), 'original PSD bitmap is unchanged');
assert.equal(await page.locator('#scan-photo').getAttribute('src'), pending.request().postDataJSON().front_image, 'real front photograph covers the white PSD placeholder');
assert(await page.locator('#scan-photo').evaluate(image => image.complete && image.naturalWidth > 0));
assert.equal(await page.locator('.scan-beam').evaluate(e => getComputedStyle(e).animationName), 'report-scan');
assert.equal(await page.locator('#p-analyzing [data-action]').count(), 1, 'only cancel is actionable; PSD icons decorative');
for (const [width, height] of [[320, 726], [375, 726], [390, 844], [414, 896], [480, 726], [375, 440]]) {
+110
View File
@@ -0,0 +1,110 @@
// Actual three-photo UI with in-memory auth/analysis. No backend writes/paid API.
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
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 base = process.env.H5_BASE_URL || 'http://127.0.0.1:8080/';
const samples = fileURLToPath(new URL('../../reference/facepp-skinanalyze/examples/images/', import.meta.url));
const browser = await chromium.launch({ headless: true });
const errors = [], unexpectedWrites = [];
try {
const page = await browser.newPage({ viewport: { width: 375, height: 726 }, isMobile: true, hasTouch: true });
page.setDefaultTimeout(20000);
page.on('pageerror', e => errors.push(e.message));
let pending, artworkFailure = false;
await page.route('**/*', route => {
const req = route.request();
if (req.url().endsWith('/api/auth/session')) return route.fulfill({ json: { ok: true, user: { id: 'scan-photo-fixture', kind: 'browser' } } });
if (req.url().endsWith('/api/skin-analyze')) { pending = route; return; }
if (req.method() !== 'GET') { unexpectedWrites.push(req.url()); return route.abort(); }
if (/\.woff2$|\/vendor\/faceapi\//.test(req.url())) return route.abort();
if (artworkFailure && /scan-base\.webp|scan-face-mask\.png/.test(req.url())) return route.abort();
return route.continue();
});
await page.addInitScript(() => {
navigator.mediaDevices.getUserMedia = () => Promise.reject(new DOMException('fixture denial', 'NotAllowedError'));
window.__scanIterations = [];
document.addEventListener('animationiteration', e => {
if (e.target.matches('.scan-beam,.scan-status')) window.__scanIterations.push({ target: e.target.className, elapsed: e.elapsedTime });
});
});
async function profile(reload = true) {
if (reload) await page.goto(base, { waitUntil: 'domcontentloaded' });
await page.click('[data-action="open-privacy"]');
if (!(await page.locator('#privacy-check').evaluate(el => el.classList.contains('checked')))) await page.click('#privacy-check');
await page.click('[data-action="agree-continue"]');
for (const selector of ['#pick-birth', '#pick-gender', '#pick-region']) {
await page.click(selector); await page.click('.ms-mobile-select.ms-show .ms-ensure'); await page.waitForTimeout(250);
}
}
async function capture() {
pending = null;
await page.click('[data-action="submit-profile"]'); await page.click('[data-action="start-camera"]');
for (const [i, name] of ['C3_180.jpg', 'C3_135.jpg', 'C3_-135.jpg'].entries()) {
await page.waitForFunction(n => document.querySelector('#cam-step').textContent.includes(n + '/3'), i + 1);
await page.waitForTimeout(1600);
await page.setInputFiles('#cam-file', path.join(samples, name));
}
await page.waitForSelector('#p-analyzing.show');
await page.waitForFunction(() => { const img = document.querySelector('#scan-photo'); return img.complete && img.naturalWidth > 0; });
assert(pending);
const sent = pending.request().postDataJSON();
assert.equal(await page.locator('#scan-photo').getAttribute('src'), sent.front_image, 'waiting face is the current FRONT photo, not the last side or a placeholder');
assert.notEqual(sent.front_image, sent.left_image); assert.notEqual(sent.front_image, sent.right_image);
assert.equal(await page.locator('#scan-photo.mirror').count(), 0, 'uploaded/system camera photos must not be mirrored again');
assert.equal(await page.locator('#scan-photo').evaluate(img => getComputedStyle(img).objectFit), 'cover');
assert.equal(await page.locator('#scan-face').evaluate(el => getComputedStyle(el).backgroundColor), 'rgb(17, 17, 17)', 'loading/error does not expose white placeholder');
return sent;
}
function rejectPending(message) { const route = pending; pending = null; return route.fulfill({ json: { ok: false, message } }); }
const cleared = async () => {
assert.equal(await page.locator('#scan-photo').getAttribute('src'), null, 'face reference cleared on exit');
assert.equal(await page.locator('#p-analyzing.scan-running').count(), 0);
};
await profile();
assert.equal(await page.locator('#scan-photo').getAttribute('src'), null, 'no face before capture');
await capture();
await page.waitForSelector('#p-analyzing.scan-artwork-ready');
assert((await page.locator('#scan-face').evaluate(el => getComputedStyle(el).maskImage)).includes('scan-face-mask.png'));
assert.equal(await page.locator('.scan-beam').evaluate(el => getComputedStyle(el).animationName), 'report-scan');
for (const [width, height] of [[320, 726], [375, 726], [390, 844], [414, 896], [480, 726], [375, 440]]) {
await page.setViewportSize({ width, height });
const photo = await page.locator('#scan-photo').boundingBox();
assert(Math.abs(photo.width - Math.min(width, 480)) < 1 && Math.abs(photo.height - height) < 1, 'same cover viewport as camera');
assert(Math.abs(photo.x - (width - photo.width) / 2) < 1 && Math.abs(photo.y) < 1, 'photo crop stays centered independently of PSD contain mask');
assert(await page.locator('.scan-cancel').isVisible());
assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth));
}
await page.setViewportSize({ width: 375, height: 726 });
if (process.env.H5_ARTIFACT_DIR) {
fs.mkdirSync(process.env.H5_ARTIFACT_DIR, { recursive: true });
await page.screenshot({ path: path.join(process.env.H5_ARTIFACT_DIR, 'scan-photo.png') });
}
await page.mouse.click(187, 350);
assert(await page.locator('#p-analyzing').isVisible(), 'clicking the face does not cancel');
await page.click('[data-action="cancel-analysis"]'); await page.waitForSelector('#s-profile.active'); await cleared();
await rejectPending('迟到的分析不应影响页面'); await page.waitForTimeout(200);
assert(!(await page.locator('#toast').textContent()).includes('迟到'));
await capture(); await rejectPending('fixture analysis failure');
await page.waitForSelector('#s-profile.active'); await cleared();
// Fast success + reduced motion still completes one actual visible cycle.
await page.emulateMedia({ reducedMotion: 'reduce' });
await capture();
await page.evaluate(() => { window.__scanIterations = []; });
const success = await page.evaluate(() => ({ ok: true, result: SkinScore.MOCK_RESULT, side: SkinScore.MOCK_SIDE, report_saved: true }));
await pending.fulfill({ json: success }); pending = null;
await page.waitForSelector('#s-report.active'); await cleared();
assert(await page.evaluate(() => window.__scanIterations.some(row => row.target === 'scan-beam' && row.elapsed >= 1)), 'fast success completes an actual cycle');
// Failed artwork/mask preserves the real photograph + status + cancel.
artworkFailure = true;
await profile(); await capture();
assert.equal(await page.locator('#p-analyzing.scan-artwork-ready').count(), 0);
assert.equal(await page.locator('#scan-face').evaluate(el => getComputedStyle(el).maskImage), 'none');
assert.equal(await page.locator('.scan-status').evaluate(el => getComputedStyle(el).clipPath), 'none');
assert.equal(await page.locator('#scan-photo').evaluate(img => getComputedStyle(img).visibility), 'visible');
await page.click('[data-action="cancel-analysis"]'); await cleared(); await rejectPending('canceled');
assert.deepEqual(errors, []); assert.deepEqual(unexpectedWrites, []);
console.log('PASS: actual three-photo waiting shows current front JPEG; original orientation/cover/PSD mask/scan; six viewport sizes; photo cleanup after cancel/error/success; stale response; one real reduced-motion cycle; image/mask failure fallback; no JS errors/backend writes');
} finally { await browser.close(); }