跟新需求文档
This commit is contained in:
+456
-154
@@ -60,6 +60,16 @@
|
||||
else if ($('#p-photoguide').classList.contains('show')) closeOverlay('p-photoguide');
|
||||
});
|
||||
|
||||
// 分析中弹层点遮罩取消:作废未决分析回调(mock 路径同样经 seq 丢弃)、关弹层、停相机、回档案页
|
||||
$('#p-analyzing').addEventListener('click', function (e) {
|
||||
if (e.target !== this) return; // 只响应遮罩空白处,不误伤卡片
|
||||
camSeq++;
|
||||
closeOverlay('p-analyzing');
|
||||
stopCamera();
|
||||
shutterLock = false; // 流程终止解锁
|
||||
showScreen('s-profile');
|
||||
});
|
||||
|
||||
/* ─── 肤质轮播 ─── */
|
||||
var SKINS = [
|
||||
{ img: 'assets/img/skin-0-dry.png', name: '干性肌肤', desc: '全脸干燥,容易紧绷、缺乏光泽' },
|
||||
@@ -142,32 +152,57 @@
|
||||
});
|
||||
|
||||
/* ─── 实时相机(getUserMedia)+ 系统相机降级 ─── */
|
||||
var IS_MOCK = /[?&]mock=1/.test(location.search); // mock:不加载 face-api、不跑检测,analyze 直返内置样本
|
||||
var camVideo = $('#cam-video'), camFile = $('#cam-file');
|
||||
var camStream = null, camLive = false, camFallback = false, camSeqCam = 0; // camSeqCam:相机启动令牌,防止迟到的授权回调泄漏摄像头
|
||||
var photos = []; // 三连拍的照片(dataURL),photos[0] 为正面照,送检用
|
||||
var photos = []; // 三连拍(dataURL),与 CAM_STEPS 同序:[0]正脸 [1]右脸(头左转) [2]左脸(头右转)
|
||||
|
||||
// 约束逐级放宽:facingMode 裸值在部分 Android 抛 OverconstrainedError,先 ideal 再退 video:true,最后才降级
|
||||
function getCamStream() {
|
||||
return navigator.mediaDevices.getUserMedia({
|
||||
video: { facingMode: { ideal: 'user' }, width: { ideal: 1280 }, height: { ideal: 1280 } },
|
||||
audio: false
|
||||
}).catch(function (err) {
|
||||
if (err && err.name === 'OverconstrainedError') {
|
||||
return navigator.mediaDevices.getUserMedia({ video: true, audio: false });
|
||||
}
|
||||
throw err;
|
||||
});
|
||||
}
|
||||
function startCamera() {
|
||||
photos = [];
|
||||
if (camStream) { stopCamera(); }
|
||||
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { enableFallback(); return; }
|
||||
camSeqCam++;
|
||||
var token = camSeqCam;
|
||||
navigator.mediaDevices.getUserMedia({
|
||||
video: { facingMode: 'user', width: { ideal: 1280 }, height: { ideal: 1280 } },
|
||||
audio: false
|
||||
}).then(function (stream) {
|
||||
getCamStream().then(function (stream) {
|
||||
if (token !== camSeqCam) { stream.getTracks().forEach(function (t) { t.stop(); }); return; } // 期间已退出拍照页:立即释放
|
||||
camStream = stream;
|
||||
camVideo.srcObject = stream;
|
||||
camLive = true;
|
||||
// iOS 来电/切后台等系统打断会结束 track:挂 ended 监听走自动重连
|
||||
stream.getTracks().forEach(function (t) {
|
||||
t.onended = function () { onCamTrackEnded(token); };
|
||||
});
|
||||
$('#s-cam').classList.add('cam-live');
|
||||
beginDetect(); // 确认相机可用后才加载检测模型(无相机权限设备不白加载)
|
||||
maybeStartDetect(); // 模型已就绪时即刻开跑检测循环
|
||||
}).catch(function () {
|
||||
if (token !== camSeqCam) return;
|
||||
enableFallback();
|
||||
});
|
||||
}
|
||||
// track 被系统结束(iOS 打断)且页面仍在拍照前台:toast 后自动重连,重连失败走 startCamera 内的 enableFallback
|
||||
function onCamTrackEnded(token) {
|
||||
if (token !== camSeqCam || !camLive) return; // 旧流/已主动停止:忽略
|
||||
if (document.hidden || currentScreen !== 's-cam') return; // 后台恢复由 visibilitychange 流程接管
|
||||
camLive = false; // 多个 track 同时 ended 只触发一次重连
|
||||
toast('摄像头连接中断,正在重连');
|
||||
startCamera();
|
||||
}
|
||||
function stopCamera() {
|
||||
camSeqCam++; // 使进行中的 getUserMedia 回调失效
|
||||
stopDetect(); // 检测循环/倒计时/无进展提示一并收尾
|
||||
$('#cam-status').classList.remove('show');
|
||||
if (camStream) { camStream.getTracks().forEach(function (t) { t.stop(); }); camStream = null; }
|
||||
camVideo.srcObject = null;
|
||||
camLive = false;
|
||||
@@ -175,6 +210,8 @@
|
||||
}
|
||||
function enableFallback() {
|
||||
camFallback = true;
|
||||
$('#cam-status').classList.remove('show'); // 与 stopCamera 对齐:去掉「检测引擎加载中」胶囊
|
||||
resetDetectState(); // 降级无检测、无补光:蒙版回到静态线框
|
||||
toast('已切换为系统相机拍摄');
|
||||
}
|
||||
// 从视频帧截取照片(镜像绘制,与预览一致;压到最长边 1280 的 JPEG)
|
||||
@@ -192,6 +229,7 @@
|
||||
}
|
||||
// 降级模式:快门调起系统相机,拍完/选完在 change 里推进流程
|
||||
camFile.addEventListener('change', function () {
|
||||
shutterLock = false; // 选完(含空选择)即解锁;推进窗口由 onPhotoCaptured 重新上锁
|
||||
var f = camFile.files && camFile.files[0];
|
||||
camFile.value = '';
|
||||
if (!f) return;
|
||||
@@ -210,130 +248,170 @@
|
||||
};
|
||||
reader.readAsDataURL(f);
|
||||
});
|
||||
// 部分系统取消相机/相册只发 cancel 不发 change:兜底解锁,防快门卡死
|
||||
camFile.addEventListener('cancel', function () { shutterLock = false; });
|
||||
|
||||
/* ─── Face++ 结果 → 设计稿报告数据(映射层) ─── */
|
||||
function num(node, key) {
|
||||
if (node == null) return 0;
|
||||
if (key != null) return Number(node[key] && (node[key].value != null ? node[key].value : node[key])) || 0;
|
||||
return Number(node.value != null ? node.value : node) || 0;
|
||||
}
|
||||
function one(x) { return Math.round(x * 10) / 10; }
|
||||
function clamp(x, lo, hi) { return Math.max(lo, Math.min(hi, x)); }
|
||||
/* ─── 姿态检测(face-api + HeadPose,引导式自动三连拍) ─── */
|
||||
var MODEL_URL = 'vendor/faceapi/models';
|
||||
var DETECT_INTERVAL = 150; // 检测帧间隔(ms)
|
||||
var OK_HOLD_MS = 600; // 姿态 ok 需连续维持时长,达标才进倒计时
|
||||
var COUNTDOWN_S = 3; // 倒计时秒数
|
||||
var NO_PROGRESS_MS = 12000; // 单步超过该时长未触发倒计时→浮出手动提示
|
||||
var FACE_NEAR = 0.35, FACE_FAR = 0.8; // 人脸框高度占画面比:<35% 太远,>80% 太近
|
||||
|
||||
function mapSkinResult(r) {
|
||||
var fw = num(r.forehead_wrinkle, 'value') === 1;
|
||||
var cf = num(r.crows_feet, 'value') === 1;
|
||||
var ef = num(r.eye_finelines, 'value') === 1;
|
||||
var gw = num(r.glabella_wrinkle, 'value') === 1;
|
||||
var nf = num(r.nasolabial_fold, 'value') === 1, nfSev = num(r.nasolabial_fold_severity, 'value');
|
||||
var ep = num(r.eye_pouch, 'value') === 1, epSev = num(r.eye_pouch_severity, 'value');
|
||||
var dc = num(r.dark_circle, 'value');
|
||||
var bh = num(r.blackhead, 'value');
|
||||
var poreCount = ['pores_forehead', 'pores_left_cheek', 'pores_right_cheek', 'pores_jaw']
|
||||
.filter(function (k) { return num(r[k], 'value') === 1; }).length;
|
||||
var acneN = (r.acne && r.acne.rectangle) ? r.acne.rectangle.length : 0;
|
||||
var ccN = (r.closed_comedones && r.closed_comedones.rectangle) ? r.closed_comedones.rectangle.length : 0;
|
||||
var spotN = (r.skin_spot && r.skin_spot.rectangle) ? r.skin_spot.rectangle.length : 0;
|
||||
var sens = r.sensitivity || {};
|
||||
var sensArea = Number(sens.sensitivity_area) || 0;
|
||||
var sensInt = Number(sens.sensitivity_intensity) || 0;
|
||||
var age = num(r.skin_age, 'value');
|
||||
var modelsPromise = null, modelsOk = false, detectDisabled = false; // detectDisabled:face-api 缺失→全程手动;模型加载失败仅当次禁用,下次进拍照页重试
|
||||
var detRunning = false, detTimer = null; // 检测循环
|
||||
var okSince = 0; // 当前连续 ok 起点(0=未在累积)
|
||||
var counting = false, cdTimer = null, cdN = 0; // 倒计时
|
||||
var nudgeTimer = null; // 12s 无进展提示
|
||||
var capturing = false; // 白屏补光抓帧中(暂停姿态判定)
|
||||
var lastHint = ''; // 提示文案去重
|
||||
|
||||
// 紧塑力:法令纹 + 眼袋(松弛下垂类)
|
||||
var firm = 10 - (nf ? [0.9, 1.5, 2.2][clamp(nfSev, 0, 2)] : 0) - (ep ? [0.7, 1.2, 1.8][clamp(epSev, 0, 2)] : 0);
|
||||
// 活力感:黑眼圈 + 眼部细纹/鱼尾纹/抬头纹/眉间纹(倦怠神采类)
|
||||
var vita = 10 - (dc > 0 ? (dc === 2 ? 1.3 : 1.0) : 0) - (ef ? 0.6 : 0) - (cf ? 0.5 : 0) - (fw ? 0.4 : 0) - (gw ? 0.3 : 0);
|
||||
// 稳定力:敏感泛红 + 痘痘/闭口/毛孔/黑头(屏障稳定类)
|
||||
var stab = 10 - (sensArea * 2.5 + sensInt / 100 * 2.0) - Math.min(acneN, 5) * 0.5 - Math.min(ccN, 5) * 0.35 - poreCount * 0.4 - bh * 0.3;
|
||||
firm = one(clamp(firm, 4, 9.8)); vita = one(clamp(vita, 4, 9.8)); stab = one(clamp(stab, 3.5, 9.8));
|
||||
var total = Math.round(clamp((firm * 0.35 + vita * 0.33 + stab * 0.32) * 10, 40, 98));
|
||||
|
||||
var dims = [
|
||||
{ name: '紧塑力', score: firm, tip: '轮廓支撑与紧致度,护肤配合由下向上的提拉手法' },
|
||||
{ name: '活力感', score: vita, tip: '神采与倦怠感,规律作息改善眼周循环' },
|
||||
{ name: '稳定力', score: stab, tip: '强韧稳态力,锁水保湿抵御刺激' }
|
||||
].sort(function (a, b) { return a.score - b.score; });
|
||||
|
||||
// 具体描述两行文案
|
||||
var sevName = ['轻度', '中度', '重度'];
|
||||
var para1;
|
||||
if (!nf && !ep) {
|
||||
para1 = '面部轮廓饱满舒展,下颌线清晰立体,中面部容积充足,肌肤回弹速度快;这种紧致状态,通常和内在胶原合成充足、整体作息节奏平稳直接相关。';
|
||||
} else {
|
||||
var parts = [];
|
||||
if (nf) parts.push('观察到' + sevName[clamp(nfSev, 0, 2)] + '法令纹痕迹,面部支撑有松弛趋势');
|
||||
if (ep) parts.push('眼部有' + sevName[clamp(epSev, 0, 2)] + '眼袋');
|
||||
para1 = parts.join(',') + '。护肤时请避免用力向下拉扯皮肤,涂抹护肤品时配合由下向上的提拉手法,重点照顾下颌线与苹果肌位置。';
|
||||
// 模型只加载一次(缓存 promise);失败清空缓存,下次进拍照页重试
|
||||
function loadModels() {
|
||||
if (!modelsPromise) {
|
||||
modelsPromise = Promise.all([
|
||||
faceapi.nets.tinyFaceDetector.loadFromUri(MODEL_URL),
|
||||
faceapi.nets.faceLandmark68Net.loadFromUri(MODEL_URL)
|
||||
]).then(function () { modelsOk = true; },
|
||||
function (err) { modelsPromise = null; throw err; });
|
||||
}
|
||||
var para2;
|
||||
var wrinkles = [];
|
||||
if (fw) wrinkles.push('抬头纹'); if (cf) wrinkles.push('鱼尾纹');
|
||||
if (ef) wrinkles.push('眼部细纹'); if (gw) wrinkles.push('眉间纹'); if (nf) wrinkles.push('法令纹');
|
||||
if (wrinkles.length === 0) {
|
||||
para2 = '面部各区域平整舒展,没有明显的纹路沟壑,只有做夸张表情时会出现极浅的动态纹路;纹路的多少,和内在胶原支撑力、日常面部肌肉活动习惯都有关系。';
|
||||
} else {
|
||||
para2 = '当前观察到' + wrinkles.join('、') + '的痕迹;纹路的多少,和内在胶原支撑力、日常面部肌肉活动习惯都有关系,日常注意表情管理与防晒,有助于减缓纹路加深。';
|
||||
}
|
||||
var found = [];
|
||||
if (spotN > 0) found.push('斑点' + spotN + '处');
|
||||
if (acneN > 0) found.push('痘痘' + acneN + '处');
|
||||
if (ccN > 0) found.push('闭口' + ccN + '处');
|
||||
if (found.length) para2 += '(本次检测到' + found.join(',') + ')';
|
||||
|
||||
return {
|
||||
total: total, age: age,
|
||||
firm: firm, vita: vita, stab: stab,
|
||||
focusName: dims[0].name, focusTip: dims[0].tip,
|
||||
para1: para1, para2: para2,
|
||||
lowest: dims[0].score
|
||||
};
|
||||
return modelsPromise;
|
||||
}
|
||||
|
||||
function renderReport(m, frontPhoto, sidePhoto) {
|
||||
$('#stat-score').textContent = m.total + '分';
|
||||
$('#stat-age').textContent = m.age + '岁';
|
||||
var setDim = function (id, score) {
|
||||
var el = $(id);
|
||||
el.textContent = score.toFixed(1);
|
||||
el.classList.toggle('dim-bad', score < 6);
|
||||
};
|
||||
setDim('#dim-firm', m.firm); setDim('#dim-vita', m.vita); setDim('#dim-stab', m.stab);
|
||||
$('#dims-note').innerHTML = '近期优先关注:' + m.focusName + '<br>' + m.focusTip;
|
||||
$('#ditem-para1').textContent = m.para1;
|
||||
$('#ditem-para2').textContent = m.para2;
|
||||
if (frontPhoto) $('#ditem-img1').src = frontPhoto;
|
||||
if (sidePhoto) $('#ditem-img2').src = sidePhoto;
|
||||
function setMask(cls) { // '' 静态白框 | 'no-face' 呼吸动画 | 'ok' 品牌绿定格
|
||||
var m = $('#cam-mask');
|
||||
m.classList.toggle('no-face', cls === 'no-face');
|
||||
m.classList.toggle('ok', cls === 'ok');
|
||||
}
|
||||
function setHint(text, ok) {
|
||||
var h = $('#cam-hint');
|
||||
if (!text) { h.classList.remove('show', 'ok'); lastHint = ''; return; }
|
||||
if (text !== lastHint) h.textContent = text; // 同文案不重复触发布局
|
||||
h.classList.add('show');
|
||||
h.classList.toggle('ok', !!ok);
|
||||
lastHint = text;
|
||||
}
|
||||
function hideNudge() { $('#cam-nudge').classList.remove('show'); }
|
||||
|
||||
// 每步进入/回退时的检测状态机重置(蒙版/提示/倒计时/无进展计时全部归位)
|
||||
function resetDetectState() {
|
||||
okSince = 0;
|
||||
lastHint = '';
|
||||
cancelCountdown();
|
||||
clearTimeout(nudgeTimer);
|
||||
hideNudge();
|
||||
setHint('');
|
||||
setMask(camLive && !IS_MOCK && !detectDisabled && !camFallback ? 'no-face' : '');
|
||||
armNudge();
|
||||
}
|
||||
// 单步 12s 未触发倒计时→底部浮出轻提示(不阻塞流程);进入下一步/倒计时/抓帧时重置
|
||||
function armNudge() {
|
||||
clearTimeout(nudgeTimer);
|
||||
hideNudge();
|
||||
if (IS_MOCK || detectDisabled || camFallback) return; // 手动模式无需提示
|
||||
if (currentScreen !== 's-cam') return;
|
||||
nudgeTimer = setTimeout(function () {
|
||||
if (currentScreen === 's-cam' && !counting && !shutterLock) $('#cam-nudge').classList.add('show');
|
||||
}, NO_PROGRESS_MS);
|
||||
}
|
||||
|
||||
/* ─── 分析请求(mock=1 用内置样本,便于离线/自动化验证) ─── */
|
||||
var MOCK_RESULT = {"skin_color":{"value":2,"confidence":0.65},"skin_age":{"value":42},"left_eyelids":{"value":0,"confidence":0.99},"right_eyelids":{"value":0,"confidence":0.99},"eye_pouch":{"value":0,"confidence":0.75},"dark_circle":{"value":1,"confidence":0.63},"forehead_wrinkle":{"value":0,"confidence":0.76},"crows_feet":{"value":1,"confidence":0.76},"eye_finelines":{"value":1,"confidence":0.86},"glabella_wrinkle":{"value":0,"confidence":0.76},"nasolabial_fold":{"value":1,"confidence":0.80},"nasolabial_fold_severity":{"value":1,"confidence":0.30},"skin_type":{"skin_type":3},"pores_forehead":{"value":0,"confidence":0.82},"pores_left_cheek":{"value":0,"confidence":0.76},"pores_right_cheek":{"value":0,"confidence":0.95},"pores_jaw":{"value":0,"confidence":0.98},"blackhead":{"value":0,"confidence":0.99},"acne":{"rectangle":[],"confidence":[]},"closed_comedones":{"rectangle":[],"confidence":[]},"mole":{"rectangle":[],"confidence":[]},"skin_spot":{"rectangle":[{"left":356,"top":494,"width":11,"height":13}],"confidence":[0.50]},"sensitivity":{"sensitivity_area":0.26,"sensitivity_intensity":65.36}};
|
||||
|
||||
function requestAnalyze(imageDataUrl, seq, done) {
|
||||
if (/[?&]mock=1/.test(location.search)) {
|
||||
setTimeout(function () { done(null, MOCK_RESULT); }, 900);
|
||||
function startCountdown() {
|
||||
counting = true;
|
||||
clearTimeout(nudgeTimer);
|
||||
hideNudge();
|
||||
cdN = COUNTDOWN_S;
|
||||
var c = $('#cam-count');
|
||||
c.textContent = cdN;
|
||||
c.classList.add('show');
|
||||
setMask('ok'); // 轮廓保持绿色
|
||||
setHint('保持不动', true);
|
||||
cdTimer = setTimeout(cdTick, 1000);
|
||||
}
|
||||
function cdTick() {
|
||||
cdN--;
|
||||
if (cdN <= 0) {
|
||||
counting = false;
|
||||
$('#cam-count').classList.remove('show');
|
||||
doCapture(); // 倒计时结束:白屏补光抓帧(与手动快门同路径)
|
||||
return;
|
||||
}
|
||||
fetch('/api/skin-analyze', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ image_base64: imageDataUrl })
|
||||
}).then(function (res) { return res.json(); })
|
||||
.then(function (data) {
|
||||
if (seq !== camSeq) return; // 流程已被重置,丢弃过期响应
|
||||
if (!data.ok) done(data.message || '检测失败,请重试');
|
||||
else done(null, data.result);
|
||||
})
|
||||
.catch(function () {
|
||||
if (seq !== camSeq) return;
|
||||
done('网络异常,请检查网络后重试');
|
||||
});
|
||||
$('#cam-count').textContent = cdN;
|
||||
cdTimer = setTimeout(cdTick, 1000);
|
||||
}
|
||||
function cancelCountdown() {
|
||||
counting = false;
|
||||
clearTimeout(cdTimer);
|
||||
$('#cam-count').classList.remove('show');
|
||||
}
|
||||
|
||||
/* ─── 拍照流程 ─── */
|
||||
function maybeStartDetect() {
|
||||
if (detRunning || IS_MOCK || detectDisabled || !modelsOk || !camLive || currentScreen !== 's-cam') return;
|
||||
detRunning = true;
|
||||
detTick();
|
||||
}
|
||||
function stopDetect() {
|
||||
detRunning = false;
|
||||
clearTimeout(detTimer);
|
||||
cancelCountdown();
|
||||
clearTimeout(nudgeTimer);
|
||||
hideNudge();
|
||||
}
|
||||
// 递归 setTimeout 而非 setInterval:检测是异步的,避免帧与帧堆叠
|
||||
function detTick() {
|
||||
if (!detRunning) return;
|
||||
detTimer = setTimeout(function () {
|
||||
if (!detRunning) return;
|
||||
detectFrame().then(detTick, detTick);
|
||||
}, DETECT_INTERVAL);
|
||||
}
|
||||
function detectFrame() {
|
||||
if (shutterLock || capturing || !camLive) return Promise.resolve(); // 抓帧/流程推进中不判姿态
|
||||
var step = camStep, seq = camSeq; // 快照:检测是异步的,帧落地时步骤/流程可能已变
|
||||
return faceapi
|
||||
.detectSingleFace(camVideo, new faceapi.TinyFaceDetectorOptions({ inputSize: 320, scoreThreshold: 0.4 }))
|
||||
.withFaceLandmarks()
|
||||
.then(function (d) {
|
||||
if (step !== camStep || seq !== camSeq) return; // 跨步骤陈帧/流程已重置:丢弃
|
||||
if (detRunning && !shutterLock && !capturing) onDetect(d);
|
||||
})
|
||||
.catch(function () { /* 单帧异常忽略,下一帧继续 */ });
|
||||
}
|
||||
function onDetect(d) {
|
||||
var hint = '', ok = false, hasFace = !!d;
|
||||
if (!hasFace) {
|
||||
hint = '未检测到人脸';
|
||||
} else {
|
||||
// 提示优先级:无人脸 > 距离 > 角度
|
||||
var ratio = d.detection.box.height / (camVideo.videoHeight || 1);
|
||||
if (ratio < FACE_NEAR) hint = '请靠近一点';
|
||||
else if (ratio > FACE_FAR) hint = '请远一点';
|
||||
else {
|
||||
// 前置预览为镜像画面 → mirrored=true;距离不合格时角度不判 ok
|
||||
var pose = HeadPose.check(d.landmarks.positions, CAM_STEPS[camStep].target, true);
|
||||
hint = pose.hint;
|
||||
ok = pose.ok;
|
||||
}
|
||||
}
|
||||
if (counting && !ok) cancelCountdown(); // 倒计时中任一帧不 ok→立即取消,回到对应提示
|
||||
setMask(counting || ok ? 'ok' : (hasFace ? '' : 'no-face'));
|
||||
setHint(counting ? '保持不动' : hint, counting || ok);
|
||||
if (counting) return;
|
||||
if (ok) {
|
||||
if (!okSince) okSince = Date.now();
|
||||
else if (Date.now() - okSince >= OK_HOLD_MS) startCountdown();
|
||||
} else {
|
||||
okSince = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── 拍照流程(三步:正脸 → 右脸出镜 → 左脸出镜) ─── */
|
||||
// target 即 HeadPose.check 的 target:2/3 右脸出镜=用户头向左转(target 'right',产物作 right_image);
|
||||
// 3/3 左脸出镜=头向右转(target 'left',产物作 left_image)。
|
||||
var CAM_STEPS = [
|
||||
{ bg: 'assets/img/cam-front.jpg', step: '1/3<br>请单人正脸出镜', guides: ['正脸居中', '取下眼镜', '光线充足'] },
|
||||
{ bg: 'assets/img/cam-left.jpg', step: '2/3<br>请单人右脸出镜', guides: ['右脸居中', '取下眼镜', '光线充足'] },
|
||||
{ bg: 'assets/img/cam-right.jpg', step: '3/3<br>请单人左脸出镜', guides: ['左脸居中', '取下眼镜', '光线充足'] }
|
||||
{ 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' }
|
||||
];
|
||||
var camStep = 0;
|
||||
|
||||
@@ -344,61 +422,106 @@
|
||||
$('#cam-guide').innerHTML = cfg.guides
|
||||
.map(function (g) { return '<span>' + g + '</span>'; })
|
||||
.join('<i></i>');
|
||||
// 步骤圆点:当前步高亮、完成步绿色打勾
|
||||
var dots = $('#cam-dots').children;
|
||||
for (var i = 0; i < dots.length; i++) {
|
||||
dots[i].classList.toggle('done', i < camStep);
|
||||
dots[i].classList.toggle('on', i === camStep);
|
||||
}
|
||||
$('#cam-mask').setAttribute('data-step', camStep); // 蒙版按步骤切换
|
||||
resetDetectState();
|
||||
}
|
||||
|
||||
var camSeq = 0; // 流程令牌:cam-back/开始拍照时递增,使未决的快门/分析回调失效
|
||||
var shutterLock = false; // 快门防连点锁:流程进行中直接忽略
|
||||
|
||||
// 照片就绪后推进流程(实时截帧与降级选图共用)
|
||||
// 照片就绪后推进流程(实时抓帧与降级选图共用):存图→步骤点变绿→300ms 过渡→下一步
|
||||
function onPhotoCaptured(dataUrl) {
|
||||
if (shutterLock && !camFallback) return;
|
||||
shutterLock = true;
|
||||
shutterLock = true; // 推进窗口上锁:降级模式 change 里已解锁,这里重新锁住直到下一步/送检
|
||||
photos[camStep] = dataUrl;
|
||||
var flash = $('#cam-flash');
|
||||
flash.classList.add('on');
|
||||
setTimeout(function () { flash.classList.remove('on'); }, 220);
|
||||
$('#cam-dots').children[camStep].classList.add('done');
|
||||
var seq = camSeq;
|
||||
setTimeout(function () {
|
||||
if (seq !== camSeq) return; // 期间已 cam-back/重新开始:放弃本次推进
|
||||
if (camStep < CAM_STEPS.length - 1) {
|
||||
camStep++;
|
||||
renderCamStep();
|
||||
shutterLock = false; // 进入下一步即解锁
|
||||
renderCamStep(); // 内含检测状态机重置
|
||||
shutterLock = false; // 进入下一步即解锁
|
||||
} else {
|
||||
// 三张拍完:送检正面照
|
||||
renderCamStep();
|
||||
camStep = 0;
|
||||
openOverlay('p-analyzing');
|
||||
var t0 = Date.now();
|
||||
requestAnalyze(photos[0], seq, function (errMsg, result) {
|
||||
if (seq !== camSeq) return; // 流程已被重置:丢弃
|
||||
var wait = Math.max(0, 1200 - (Date.now() - t0)); // 分析中至少展示 1.2s,避免闪烁
|
||||
setTimeout(function () {
|
||||
if (seq !== camSeq) return;
|
||||
closeOverlay('p-analyzing');
|
||||
stopCamera();
|
||||
if (errMsg) {
|
||||
toast(errMsg);
|
||||
showScreen('s-profile'); // 回档案页,允许重新提交再拍
|
||||
shutterLock = false;
|
||||
return;
|
||||
}
|
||||
renderReport(mapSkinResult(result), photos[0], photos[1]);
|
||||
showScreen('s-report');
|
||||
shutterLock = false; // 流程结束进入报告后解锁
|
||||
}, wait);
|
||||
});
|
||||
submitPhotos(seq); // 三张拍完:送检
|
||||
}
|
||||
}, 260);
|
||||
}, 300);
|
||||
}
|
||||
|
||||
// 白屏补光抓拍:全屏纯白瞬间点亮→约300ms 后抓全分辨率帧→保持300ms 再淡出(总 600–900ms)
|
||||
function doCapture() {
|
||||
if (shutterLock) return;
|
||||
if (!camLive) return;
|
||||
shutterLock = true;
|
||||
capturing = true;
|
||||
cancelCountdown();
|
||||
clearTimeout(nudgeTimer);
|
||||
hideNudge();
|
||||
var seq = camSeq;
|
||||
var fill = $('#cam-fill');
|
||||
fill.classList.add('on'); // .on 无过渡,瞬间点亮
|
||||
if (navigator.vibrate) navigator.vibrate(30);
|
||||
setTimeout(function () {
|
||||
var shot = captureFromVideo();
|
||||
setTimeout(function () {
|
||||
fill.classList.remove('on'); // 移除 .on 恢复过渡,淡出
|
||||
capturing = false;
|
||||
if (seq !== camSeq) return; // 期间已退出/重置:不再推进(遮罩仍正常淡出)
|
||||
if (!shot) { shutterLock = false; okSince = 0; toast('相机尚未就绪,请稍候'); return; } // 重置 okSince:避免陈旧累积立即重启倒计时
|
||||
onPhotoCaptured(shot);
|
||||
}, 300);
|
||||
}, 300);
|
||||
}
|
||||
|
||||
// 三张拍完:三视图送检(正脸=photos[0],左=photos[2],右=photos[1])
|
||||
function submitPhotos(seq) {
|
||||
camStep = 0;
|
||||
openOverlay('p-analyzing');
|
||||
var t0 = Date.now();
|
||||
requestAnalyze(photos, seq, function (errMsg, payload) {
|
||||
if (seq !== camSeq) return; // 流程已被重置:丢弃
|
||||
var wait = Math.max(0, 1200 - (Date.now() - t0)); // 分析中至少展示 1.2s,避免闪烁
|
||||
setTimeout(function () {
|
||||
if (seq !== camSeq) return;
|
||||
closeOverlay('p-analyzing');
|
||||
stopCamera();
|
||||
shutterLock = false; // 流程结束解锁
|
||||
if (errMsg) {
|
||||
toast(errMsg);
|
||||
showScreen('s-profile'); // 回档案页,允许重新提交再拍
|
||||
return;
|
||||
}
|
||||
var vm = null;
|
||||
try {
|
||||
vm = SkinScore.buildViewModel(payload.result, payload.side, {
|
||||
realAge: +($('#f-age').value || 0) || null,
|
||||
skinType: SKINS[skinIndex].name
|
||||
});
|
||||
} catch (e) { vm = null; }
|
||||
if (!vm) {
|
||||
toast('报告生成失败,请重新拍摄');
|
||||
showScreen('s-profile');
|
||||
return;
|
||||
}
|
||||
renderReport(vm, photos[0], photos[1]); // img1=正脸实拍,img2=右脸实拍
|
||||
photos = []; // 报告已渲染(图片 src 已挂):释放三连拍 dataURL 内存
|
||||
showScreen('s-report');
|
||||
}, wait);
|
||||
});
|
||||
}
|
||||
|
||||
function shutter() {
|
||||
if (shutterLock) return;
|
||||
if (camLive) {
|
||||
var shot = captureFromVideo();
|
||||
if (!shot) { toast('相机尚未就绪,请稍候'); return; }
|
||||
onPhotoCaptured(shot);
|
||||
if (!camVideo.videoWidth) { toast('相机尚未就绪,请稍候'); return; }
|
||||
doCapture(); // 手动兜底:任何时刻可拍当前步,同样走白屏补光,不依赖检测
|
||||
} else if (camFallback) {
|
||||
shutterLock = true; // 前置上锁防连点:change/cancel 回调里解锁
|
||||
camFile.click(); // 降级:调起系统相机,回调里推进
|
||||
} else {
|
||||
// 相机还在启动中
|
||||
@@ -406,6 +529,150 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── 分析请求(mock=1 直返 SkinScore 内置样本,便于离线/自动化验证) ─── */
|
||||
var ANALYZE_TIMEOUT_MS = 45000; // 检测服务超时阈值:超时按「开小差」处理回档案页
|
||||
function requestAnalyze(ph, seq, done) {
|
||||
if (IS_MOCK && typeof SkinScore !== 'undefined') {
|
||||
setTimeout(function () {
|
||||
if (seq !== camSeq) return; // mock 同样尊重取消/重置
|
||||
done(null, { result: SkinScore.MOCK_RESULT, side: SkinScore.MOCK_SIDE });
|
||||
}, 900);
|
||||
return;
|
||||
}
|
||||
var ctrl = typeof AbortController !== 'undefined' ? new AbortController() : null;
|
||||
var timer = setTimeout(function () { if (ctrl) ctrl.abort(); }, ANALYZE_TIMEOUT_MS);
|
||||
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] }),
|
||||
signal: ctrl ? ctrl.signal : undefined // 老内核无 AbortController:仅靠令牌丢弃迟到响应
|
||||
}).then(function (res) { return res.json(); })
|
||||
.then(function (data) {
|
||||
clearTimeout(timer);
|
||||
if (seq !== camSeq) return; // 流程已被重置,丢弃过期响应
|
||||
if (!data.ok) done(data.message || '检测失败,请重试');
|
||||
else done(null, { result: data.result, side: data.side });
|
||||
})
|
||||
.catch(function (err) {
|
||||
clearTimeout(timer);
|
||||
if (seq !== camSeq) return;
|
||||
if (err && err.name === 'AbortError') done('检测服务开小差了,请稍候重试'); // 45s 超时主动中断
|
||||
else done('网络异常,请检查网络后重试');
|
||||
});
|
||||
}
|
||||
|
||||
/* ─── SkinScore viewModel → 报告渲染(评分公式见 js/skin-score.js,不直接向用户展示 P 值) ─── */
|
||||
var LV_CLASS = { '良好': 'lv-good', '改善': 'lv-warn', '预警': 'lv-bad' };
|
||||
var CAT_DOM = {
|
||||
contour: { s: '#s-contour', lv: '#lv-contour', tx: '#ditem-para1' },
|
||||
wrinkles: { s: '#s-wrinkles', lv: '#lv-wrinkles', tx: '#ditem-para2' },
|
||||
smooth: { s: '#s-smooth', lv: '#lv-smooth', tx: '#tx-smooth' },
|
||||
tone: { s: '#s-tone', lv: '#lv-tone', tx: '#tx-tone' },
|
||||
sens: { s: '#s-sens', lv: '#lv-sens', tx: '#tx-sens' },
|
||||
acne: { s: '#s-acne', lv: '#lv-acne', tx: '#tx-acne' }
|
||||
};
|
||||
|
||||
function esc(s) {
|
||||
return String(s == null ? '' : s)
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
function setDim(scoreSel, lvSel, dim) {
|
||||
var el = $(scoreSel), lv = $(lvSel);
|
||||
if (dim.S == null) {
|
||||
el.textContent = '-.-';
|
||||
lv.textContent = '未检出';
|
||||
lv.className = 'dim-lv';
|
||||
return;
|
||||
}
|
||||
el.textContent = dim.S.toFixed(1);
|
||||
lv.textContent = dim.level;
|
||||
lv.className = 'dim-lv ' + (LV_CLASS[dim.level] || '');
|
||||
}
|
||||
|
||||
function setCat(key, cat) {
|
||||
var dom = CAT_DOM[key];
|
||||
var s = $(dom.s), lv = $(dom.lv);
|
||||
if (cat.P == null) { // 缺数据分类:显示说明,不显示分数
|
||||
s.textContent = '本次未检出有效数据';
|
||||
s.className = 'cat-s cat-na';
|
||||
lv.style.display = 'none';
|
||||
} else {
|
||||
s.textContent = cat.S.toFixed(1);
|
||||
s.className = 'cat-s';
|
||||
lv.style.display = '';
|
||||
lv.textContent = cat.level;
|
||||
lv.className = 'cat-badge ' + (LV_CLASS[cat.level] || '');
|
||||
}
|
||||
$(dom.tx).textContent = cat.text;
|
||||
}
|
||||
|
||||
function renderReport(vm, frontPhoto, sidePhoto) {
|
||||
// 头部:综合评分(vm.total 缺失时回退三维度 S 均分×10)
|
||||
var total = vm.total;
|
||||
if (total == null) {
|
||||
var ds = [vm.dims.firm.S, vm.dims.vita.S, vm.dims.stab.S], sum = 0, n = 0, i;
|
||||
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 + '岁' : '--岁';
|
||||
$('#stat-age-note').textContent = vm.ageClampNote || ''; // 肤龄钳制留痕(小字灰)
|
||||
|
||||
// 三维度:S 值(1 位小数) + 档位(良好绿/改善琥珀/预警红)
|
||||
setDim('#dim-firm', '#dim-firm-lv', vm.dims.firm);
|
||||
setDim('#dim-vita', '#dim-vita-lv', vm.dims.vita);
|
||||
setDim('#dim-stab', '#dim-stab-lv', vm.dims.stab);
|
||||
$('#dims-note').innerHTML = vm.focus.name
|
||||
? '近期优先关注:' + esc(vm.focus.name) + '<br>' + esc(vm.focus.tip)
|
||||
: '近期优先关注:<br>---';
|
||||
|
||||
// 三个 Tab 面板:各所辖两个分类(S + 档位徽章 + 话术正文)
|
||||
var keys = ['contour', 'wrinkles', 'smooth', 'tone', 'sens', 'acne'];
|
||||
for (var i = 0; i < keys.length; i++) setCat(keys[i], vm.cats[keys[i]]);
|
||||
if (frontPhoto) $('#ditem-img1').src = frontPhoto;
|
||||
if (sidePhoto) $('#ditem-img2').src = sidePhoto;
|
||||
|
||||
// 降级标注(下颌线未参与评分等,非空才展示)
|
||||
var notesEl = $('#vm-notes');
|
||||
if (vm.notes && vm.notes.length) {
|
||||
notesEl.innerHTML = vm.notes.map(function (t) { return '*' + esc(t); }).join('<br>');
|
||||
notesEl.style.display = '';
|
||||
} else {
|
||||
notesEl.style.display = 'none';
|
||||
}
|
||||
|
||||
// 综合文案 / 生活方式 / 精华推荐
|
||||
$('#vm-summary').textContent = vm.summary;
|
||||
$('#vm-lifestyle').textContent = vm.lifestyle;
|
||||
$('#essence-name').textContent = vm.essence.name;
|
||||
$('#essence-text').textContent = vm.essence.text;
|
||||
$('#essence-triggers').textContent = vm.essence.triggers.length ? '触发:' + vm.essence.triggers.join('、') : '';
|
||||
}
|
||||
|
||||
/* ─── 相机可用后加载检测模型(加载完成前快门即手动模式) ─── */
|
||||
function beginDetect() {
|
||||
if (IS_MOCK || camFallback) { resetDetectState(); return; }
|
||||
if (typeof faceapi === 'undefined') {
|
||||
detectDisabled = true; // face-api 本身缺失:全程手动,不再重试
|
||||
resetDetectState();
|
||||
toast('自动识别不可用,已切换手动拍摄');
|
||||
return;
|
||||
}
|
||||
detectDisabled = false; // 上次模型加载失败允许重试(modelsPromise 失败时已清空,loadModels 会重新拉取)
|
||||
resetDetectState();
|
||||
if (!modelsOk) $('#cam-status').classList.add('show'); // “检测引擎加载中…”
|
||||
loadModels().then(function () {
|
||||
$('#cam-status').classList.remove('show');
|
||||
maybeStartDetect();
|
||||
}).catch(function () {
|
||||
$('#cam-status').classList.remove('show');
|
||||
detectDisabled = true; // 仅禁用当次;下次进拍照页 beginDetect 复位后重试
|
||||
resetDetectState();
|
||||
if (currentScreen === 's-cam') toast('自动识别不可用,已切换手动拍摄');
|
||||
});
|
||||
}
|
||||
|
||||
/* ─── 隐私勾选 ─── */
|
||||
var agreed = false;
|
||||
$('#privacy-row').addEventListener('click', function (e) {
|
||||
@@ -456,15 +723,16 @@
|
||||
closeOverlay('p-photoguide');
|
||||
camSeq++; // 使旧的快门/分析回调失效
|
||||
camStep = 0;
|
||||
photos = []; // 新一轮三连拍(从 startCamera 挪出:后台恢复续拍时保留已拍)
|
||||
shutterLock = false; // 重新进入流程,保险解锁
|
||||
renderCamStep();
|
||||
showScreen('s-cam');
|
||||
startCamera();
|
||||
startCamera(); // beginDetect 在其成功回调里:确认相机可用才加载模型
|
||||
},
|
||||
'cam-back': function () {
|
||||
camSeq++; // 使未决的快门/分析回调失效,避免回退被旧定时器顶回
|
||||
shutterLock = false; // 中途退出也解锁
|
||||
if (camStep > 0) { camStep--; renderCamStep(); }
|
||||
if (camStep > 0) { camStep--; renderCamStep(); } // renderCamStep 内含检测状态机重置
|
||||
else { stopCamera(); showScreen('s-profile'); }
|
||||
},
|
||||
'shutter': shutter,
|
||||
@@ -481,6 +749,40 @@
|
||||
if (fn) fn();
|
||||
});
|
||||
|
||||
/* ─── 页面隐藏/来电生命周期:隐藏即停相机与全部定时器,回前台从当前步恢复(保留 photos[] 与 camStep) ─── */
|
||||
function handleCamHidden() {
|
||||
if (currentScreen !== 's-cam') return;
|
||||
camSeq++; // 使未决的快门/推进/分析回调失效(白屏定时器醒来即自洁退出)
|
||||
shutterLock = false;
|
||||
capturing = false;
|
||||
$('#cam-fill').classList.remove('on'); // 白屏若亮着立即熄灭
|
||||
closeOverlay('p-analyzing'); // 分析中被打断:回调已作废,弹层不能残留
|
||||
stopCamera(); // 停检测循环/倒计时/nudge 并释放摄像头(camSeqCam++ 令迟到授权回调失效)
|
||||
}
|
||||
function handleCamVisible() {
|
||||
if (currentScreen !== 's-cam') return;
|
||||
camSeq++; // 双保险:隐藏期间残留的迟到回调一并作废
|
||||
renderCamStep(); // 重置当前步检测状态机,从当前步继续
|
||||
if (!camFallback) startCamera(); // 降级模式无流可起,仅重置状态机;重走 camSeqCam 令牌防复活
|
||||
}
|
||||
document.addEventListener('visibilitychange', function () {
|
||||
if (document.hidden) handleCamHidden(); else handleCamVisible();
|
||||
});
|
||||
window.addEventListener('pagehide', handleCamHidden);
|
||||
window.addEventListener('pageshow', function (e) {
|
||||
if (e.persisted) handleCamVisible(); // bfcache 恢复(不一定伴随 visibilitychange)
|
||||
});
|
||||
|
||||
/* ─── 初始化 ─── */
|
||||
renderCamStep();
|
||||
// 仅调试用:?mock=1&screen=report 直渲 mock 报告页(供无头浏览器截图验证布局,非正式入口)
|
||||
if (IS_MOCK && /[?&]screen=report/.test(location.search) && typeof SkinScore !== 'undefined') {
|
||||
showScreen('s-report');
|
||||
try {
|
||||
renderReport(SkinScore.buildViewModel(SkinScore.MOCK_RESULT, SkinScore.MOCK_SIDE, {
|
||||
realAge: +($('#f-age').value || 0) || 28, // 调试档案缺省 28 岁
|
||||
skinType: SKINS[skinIndex].name
|
||||
}), null, null);
|
||||
} catch (e) { /* 调试入口异常不阻塞正式流程 */ }
|
||||
}
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user