feat: 拍摄流程对齐 Figma(正→左→右顺序/描摹轮廓蒙版/正脸只提示回正) + 报告页按 Figma 08 重排

- 拍照顺序 正脸→左脸→右脸:步骤文案/语音文件(step2-leftface/step3-rightface)/payload 归属对齐
- 蒙版轮廓由 Figma 拍照示意位图 alpha 镂空区逐像素描摹生成(0 正脸含耳/1 左脸鼻朝左/2 右脸鼻朝右),框外纯黑
- 侧脸步预览与抓帧不镜像(左右不再迷惑),HeadPose 侧脸自校准阈值 30 帧防误翻转
- 语音:「保持不动」播完再进倒计时(afterCurrent 接续,不再拦腰切断)
- 正脸步角度不合格只提示「请正对屏幕」,阈值放宽 |yaw|≤15°/|pitch|≤18°;转头提示仅侧脸步
- 抓帧改完整帧(修 Face++ INVALID_IMAGE_FACE)
- 报告页按 Figma 08:内联白底顶栏(返回+标题)、总分双独立白卡(30px 大数字+15px 单位后缀)、
  维度短竖分隔线+最低分标红、Tab 黄条、免责左对齐、护理建议去 tag 加粗+收尾句、
  产品大标题移出白卡、COREⅡ 换 Figma 导出 SVG 字标、产品图 151×174、卡圆角 10px
- 精华卡判据措辞用户化(均分档→综合均分/最差项约束→最低分维度/年龄组档→年龄段,引擎值不动)
- 修报告页滚动 P0(去 position:relative 覆写),文档同步(语音文档/README)
This commit is contained in:
Ubuntu
2026-09-28 00:57:28 +08:00
parent 0e00f3e938
commit 4e3d915b7d
16 changed files with 250 additions and 207 deletions
+71 -42
View File
@@ -168,13 +168,16 @@
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 同序:[0]正脸 [1]右脸(头左转) [2]左脸(头右转)
var photos = []; // 三连拍(dataURL),与 CAM_STEPS 同序:[0]正脸 [1]左脸(头右转) [2]右脸(头左转)
// 约束逐级放宽:facingMode 裸值在部分 Android 抛 OverconstrainedError,先 ideal 再退 video:true,最后才降级
var camFacing = 'user'; // 当前镜头:'user' 前置 | 'environment' 后置(翻转按钮切换)
var camMirrored = true; // 预览是否镜像(前置镜像,后置不镜像)
var mirrorCalibrated = false; // 镜像自校准:本轮是否已翻转过一次(防振荡,一次校准管整个会话)
var camMirrored = true; // 前置镜像开关:仅正脸步生效(自拍习惯);侧脸步始终不镜像(左右不再迷惑,与取景轮廓方向一致)
var sideYawFlip = false; // 侧脸步语义 yaw 反向标志:个别机型前置帧本身已镜像时由自校准翻转
var mirrorCalibrated = false; // 侧脸自校准:本轮是否已翻转过一次(防振荡,一次校准管整个会话)
var oppFrames = 0; // 侧脸步语义 yaw 连续反向帧计数
// 预览/抓帧是否镜像:正脸步跟随 camMirrored,侧脸步恒不镜像
function effMirror() { return camMirrored && CAM_STEPS[camStep].target === 'front'; }
function getCamStream() {
return navigator.mediaDevices.getUserMedia({
video: { facingMode: { ideal: camFacing }, width: { ideal: 1280 }, height: { ideal: 1280 } },
@@ -207,7 +210,7 @@
if (token !== camSeqCam) { stream.getTracks().forEach(function (t) { t.stop(); }); return; } // 期间已退出拍照页:立即释放
camStream = stream;
camVideo.srcObject = stream;
camVideo.classList.toggle('mirror', camMirrored);
camVideo.classList.toggle('mirror', effMirror());
// iOS 来电/切后台等系统打断会结束 track:挂 ended 监听走自动重连
stream.getTracks().forEach(function (t) {
t.onended = function () { onCamTrackEnded(token); };
@@ -247,22 +250,18 @@
resetDetectState(); // 降级无检测、无补光:蒙版回到静态线框
toast('已切换为系统相机拍摄');
}
// 从视频帧截取照片(镜像绘制,与预览一致;压到最长边 1280 的 JPEG)
// 从视频帧截取照片(完整帧,不镜像侧脸步;压到最长边 1280 的 JPEG)
// 抓完整帧而非取景框可见区:新 UI 轮廓外全黑,用户只关注轮廓内人脸;按可见区裁切在侧脸/偏站位时会切掉人脸(Face++ INVALID_IMAGE_FACE)
function captureFromVideo() {
var vw = camVideo.videoWidth, vh = camVideo.videoHeight;
if (!vw || !vh) return null;
// 只取取景框可见区域(object-fit:cover 的中心裁切):照片 = 用户当时看到的画面
var box = camVideo.getBoundingClientRect();
var scaleFit = Math.max(box.width / vw, box.height / vh);
var visW = Math.min(vw, box.width / scaleFit), visH = Math.min(vh, box.height / scaleFit);
var sx = (vw - visW) / 2, sy = (vh - visH) / 2;
var scale = Math.min(1, 1280 / Math.max(visW, visH));
var cw = Math.round(visW * scale), ch = Math.round(visH * scale);
var scale = Math.min(1, 1280 / Math.max(vw, vh));
var cw = Math.round(vw * scale), ch = Math.round(vh * scale);
var canvas = document.createElement('canvas');
canvas.width = cw; canvas.height = ch;
var ctx = canvas.getContext('2d');
if (camMirrored) { ctx.translate(cw, 0); ctx.scale(-1, 1); } // 前置镜像,与预览一致
ctx.drawImage(camVideo, sx, sy, visW, visH, 0, 0, cw, ch);
if (effMirror()) { ctx.translate(cw, 0); ctx.scale(-1, 1); } // 与预览一致:正脸步前置镜像,侧脸步不镜像(照片=摄像头原始视角)
ctx.drawImage(camVideo, 0, 0, vw, vh, 0, 0, cw, ch);
return canvas.toDataURL('image/jpeg', 0.88);
}
// 文件 → 统一压成最长边 1280 的 JPEG → 推进流程(降级系统相机与相册选图共用)
@@ -303,7 +302,7 @@
/* ─── 语音播报(WebAudio 单通道;assets/voice/<id>.mp3,缺失静默跳过;同条 4s 去重)
文案/文件名契约:docs/语音播报内容设计文档_V1.0.md ─── */
var VOICE = {
step: ['step1-front', 'step2-rightface', 'step3-leftface'],
step: ['step1-front', 'step2-leftface', 'step3-rightface'],
hint: {
'未检测到人脸': 'hint-noface', '请靠近一点': 'hint-near', '请远一点': 'hint-far',
'请正对屏幕': 'hint-front', '请向左转头': 'hint-turn-left', '请向右转头': 'hint-turn-right',
@@ -312,7 +311,7 @@
countdown: 'countdown-321', shot: 'shot-done', analyzing: 'analyzing', fail: 'analyze-fail'
};
var voice = (function () {
var ctx = null, lastAt = {}, cur = null, files = null; // files:null=清单未加载;{}=无可用语音
var ctx = null, lastAt = {}, cur = null, curEnd = 0, files = null; // files:null=清单未加载;{}=无可用语音
// 清单制:只播 assets/voice/manifest.json 里声明的文件,不逐个探文件(避免控制台 404 噪音)
fetch('assets/voice/manifest.json')
.then(function (r) { return r.ok ? r.json() : { files: [] }; })
@@ -333,15 +332,29 @@
if (!ab || !ctx) return;
ctx.decodeAudioData(ab, function (buf) {
if (cur) { try { cur.stop(); } catch (e) {} }
cur = ctx.createBufferSource();
cur.buffer = buf;
cur.connect(ctx.destination);
cur.start(0);
var src = ctx.createBufferSource();
src.buffer = buf;
src.connect(ctx.destination);
cur = src;
curEnd = Date.now() + buf.duration * 1000;
src.onended = function () { if (cur === src) cur = null; };
src.start(0);
}, function () { /* 解码失败忽略 */ });
})
.catch(function () { /* 网络异常忽略 */ });
}
return { unlock: unlock, play: play };
// 等当前播报自然收尾后执行 cb(倒计时接续「保持不动」,不拦腰切断);无播报在执行则立即执行
// 返回取消函数;注意:cur 被新播报 stop 也会触发 onended,调用方需配合 cancelCountdown 等路径取消
function afterCurrent(cb) {
if (!cur || Date.now() >= curEnd - 60) { cb(); return null; }
var src = cur, cancelled = false;
src.onended = function () {
if (cur === src) cur = null;
if (!cancelled) cb();
};
return function () { cancelled = true; };
}
return { unlock: unlock, play: play, afterCurrent: afterCurrent };
})();
document.addEventListener('click', voice.unlock); // 任意点击即完成解锁
@@ -360,6 +373,7 @@
var detRunning = false, detTimer = null; // 检测循环
var okSince = 0; // 当前连续 ok 起点(0=未在累积)
var counting = false, cdTimer = null, cdN = 0; // 倒计时
var cdDeferCancel = null; // 倒计时等播报收尾的取消句柄(afterCurrent)
var nudgeTimer = null; // 12s 无进展提示
var capturing = false; // 白屏补光抓帧中(暂停姿态判定)
var lastHint = ''; // 提示文案去重
@@ -419,15 +433,21 @@
}
function startCountdown() {
counting = true;
counting = true; // 立即置位:等待播报收尾期间姿态被破坏,走 cancelCountdown 统一取消
clearTimeout(nudgeTimer);
hideNudge();
setMask('ok'); // 轮廓保持绿色
setHint('保持不动', true, /* silent= */true); // 倒计时前不再抢播「保持不动」(会被 321 立刻切断,听着像吞字)
// 「保持不动」等仍在播放的提示音自然播完再进倒计时,画面数字与「三、二、一」同步起播
cdDeferCancel = voice.afterCurrent(beginCountdown);
}
function beginCountdown() {
cdDeferCancel = null;
if (!counting) return; // 等待期间已被取消(姿态破坏/退出拍照页)
cdN = COUNTDOWN_S;
var c = $('#cam-count');
c.textContent = cdN;
c.classList.add('show');
setMask('ok'); // 轮廓保持绿色
setHint('保持不动', true, /* silent= */true); // 倒计时前不再抢播「保持不动」(会被 321 立刻切断,听着像吞字)
voice.play(VOICE.countdown); // 「三、二、一」与数字同步起播
cdTimer = setTimeout(cdTick, 1000);
}
@@ -444,6 +464,7 @@
}
function cancelCountdown() {
counting = false;
if (cdDeferCancel) { cdDeferCancel(); cdDeferCancel = null; } // 取消尚未起播的倒计时接续
clearTimeout(cdTimer);
$('#cam-count').classList.remove('show');
}
@@ -495,19 +516,20 @@
if (ratio < FACE_NEAR) hint = '请靠近一点';
else if (ratio > FACE_FAR) hint = '请远一点';
else {
// 镜像标志跟随镜头(前置 true/后置 false);距离不合格时角度不判 ok
var pose = HeadPose.check(d.landmarks.positions, CAM_STEPS[camStep].target, camMirrored);
// 镜像自校准:侧脸步用户实际转向与语义 yaw 持续相反(本机镜像策略与 camMirrored 假设不符),
// 连续 5 帧强反向即翻转镜像约定一次(翻转后语义回正,通常立即姿态合格)
// 距离不合格时角度不判 ok。镜像参数:正脸步跟随镜头(前置 true/后置 false);
// 侧脸步预览不镜像,特征点即原始视角,标准机型恒为 false,个别前置帧已镜像的机型由自校准翻转 sideYawFlip
var tgt = CAM_STEPS[camStep].target;
var pose = HeadPose.check(d.landmarks.positions, tgt, tgt === 'front' ? camMirrored : sideYawFlip);
// 侧脸自校准:个别机型前置帧本身已镜像(语义 yaw 与实际转向持续相反)时自救——
// 需连续 30 帧(~10s)强反向才翻转一次:阈值要高,否则用户转错方向 1~2s 就会被误判为镜像机,
// 反而把错误的侧脸当合格抓走(左右图归属错乱)。翻转只动判定方向,不动预览画面
if (tgt !== 'front' && pose.yaw != null && !mirrorCalibrated) {
var wantSign = tgt === 'right' ? 1 : -1;
if (pose.yaw * wantSign < -12) {
if (++oppFrames >= 5) {
camMirrored = !camMirrored;
if (++oppFrames >= 30) {
sideYawFlip = !sideYawFlip;
mirrorCalibrated = true;
oppFrames = 0;
camVideo.classList.toggle('mirror', camMirrored);
}
} else oppFrames = 0;
}
@@ -528,13 +550,13 @@
}
}
/* ─── 拍照流程(三步:正脸 → 右脸出镜 → 左脸出镜) ─── */
// target 即 HeadPose.check 的 target:2/3 右脸出镜=用户头向左转(target 'right',产物作 right_image);
// 3/3 左脸出镜=头向右转(target 'left',产物作 left_image)。
/* ─── 拍照流程(三步:正脸 → 左脸出镜 → 右脸出镜) ─── */
// target 即 HeadPose.check 的 target:2/3 左脸出镜=用户头向右转(target 'left',产物作 left_image);
// 3/3 右脸出镜=头向左转(target 'right',产物作 right_image)。
var CAM_STEPS = [
{ bg: 'assets/img/cam-front.jpg', step: '1/3<br>请单人正脸出镜', guides: ['正脸居中', '取下眼镜', '光线充足'], target: 'front' },
{ bg: 'assets/img/cam-left.jpg', step: '2/3<br>请单人右脸出镜', guides: ['右脸居中', '取下眼镜', '光线充足'], target: 'right' },
{ bg: 'assets/img/cam-right.jpg', step: '3/3<br>请单人左脸出镜', guides: ['左脸居中', '取下眼镜', '光线充足'], target: 'left' }
{ bg: 'assets/img/cam-left.jpg', step: '2/3<br>请单人左脸出镜', guides: ['左脸居中', '取下眼镜', '光线充足'], target: 'left' },
{ bg: 'assets/img/cam-right.jpg', step: '3/3<br>请单人右脸出镜', guides: ['右脸居中', '取下眼镜', '光线充足'], target: 'right' }
];
var camStep = 0;
@@ -552,6 +574,7 @@
dots[i].classList.toggle('on', i === camStep);
}
$('#cam-mask').setAttribute('data-step', camStep); // 蒙版按步骤切换
camVideo.classList.toggle('mirror', effMirror()); // 侧脸步预览切回不镜像(正脸步恢复前置镜像)
resetDetectState();
voice.play(VOICE.step[camStep]); // 步骤引导语音
}
@@ -603,7 +626,7 @@
}, 300);
}
// 三张拍完:三视图送检(正脸=photos[0],左=photos[2],右=photos[1])
// 三张拍完:三视图送检(正脸=photos[0],左=photos[1],右=photos[2])
function submitPhotos(seq) {
camStep = 0;
openOverlay('p-analyzing');
@@ -685,7 +708,7 @@
fetch('/api/skin-analyze', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ front_image: ph[0], left_image: ph[2], right_image: ph[1] }),
body: JSON.stringify({ front_image: ph[0], left_image: ph[1], right_image: ph[2] }),
signal: ctrl ? ctrl.signal : undefined // 老内核无 AbortController:仅靠令牌丢弃迟到响应
}).then(function (res) { return res.json(); })
.then(function (data) {
@@ -911,8 +934,9 @@
for (i = 0; i < 3; i++) if (typeof ds[i] === 'number') { sum += ds[i]; n++; }
if (n) total = Math.round(sum / n * 10);
}
$('#stat-score').textContent = total != null ? total + '分' : '--分';
$('#stat-age').textContent = vm.skinAgeDisplay != null ? vm.skinAgeDisplay + '岁' : '--岁';
// 设计稿:大数字 30px/400 + 单位 15px/500(分/岁为小字后缀)
$('#stat-score').innerHTML = total != null ? '<b>' + total + '</b><i>分</i>' : '<b>--</b><i>分</i>';
$('#stat-age').innerHTML = vm.skinAgeDisplay != null ? '<b>' + vm.skinAgeDisplay + '</b><i>岁</i>' : '<b>--</b><i>岁</i>';
$('#stat-age-note').textContent = ''; // 肌龄小字按产品要求不展示(钳制留痕 vm.ageClampNote 仍随数据返回)
// 三维度:S 值(1 位小数),最低分标红(设计稿无档位徽章)
@@ -954,17 +978,22 @@
}
// 护理建议(V2.3 §8 五段:肤质定制/最差2分类/精华/生活方式);综合分析与生活方式通栏已按需求移除
// 展示对齐 Figma:纯「序号、正文」,不显示段名【tag】、不加粗
var careBox = $('#care-list');
if (careBox) {
careBox.innerHTML = (vm.careAdvice || []).map(function (s, i) {
return '<p class="care-item"><b>' + (i + 1) + '、【' + esc(s.tag) + '】</b>' + esc(s.text) + '</p>';
return '<p class="care-item">' + (i + 1) + '、' + esc(s.text) + '</p>';
}).join('');
}
// 精华推荐卡
$('#essence-name').textContent = vm.essence.name;
$('#essence-text').textContent = vm.essence.text;
$('#essence-triggers').textContent = vm.essence.triggers.length ? '触发:' + vm.essence.triggers.join('、') : '';
// 命中判据:引擎内部名 → 用户可读措辞(引擎值被真值回归逐字锁定,仅展示层映射)
var TRIGGER_LABEL = { '均分档': '综合均分', '最差项约束': '最低分维度', '年龄组档': '年龄段' };
$('#essence-triggers').textContent = vm.essence.triggers.length
? '推荐依据:' + vm.essence.triggers.map(function (t) { return TRIGGER_LABEL[t] || t; }).join('、')
: '';
}
/* ─── 相机可用后加载检测模型(加载完成前快门即手动模式) ─── */