diff --git a/.gitignore b/.gitignore index b5f82bc..daa4872 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,4 @@ node_modules/ *.log __pycache__/ aipkey.bash +h5/data/ diff --git a/docs/语音播报内容设计文档_V1.0.md b/docs/语音播报内容设计文档_V1.0.md new file mode 100644 index 0000000..db59aca --- /dev/null +++ b/docs/语音播报内容设计文档_V1.0.md @@ -0,0 +1,63 @@ +# 巨子测肤 · 1'me 语音播报内容设计文档 V1.0 + +> 用途:语音文件生成与前端播放的唯一契约。生成方按本文件的「文件名/文案/时长」产出音频;前端按「文件名/触发时机」播放。 +> 落地目录:`h5/assets/voice/<文件名>`(与代码约定一致,缺失文件前端静默跳过,不报错)。 + +## 1. 音频格式约定(已定) + +| 项 | 约定 | 理由 | +|---|---|---| +| 容器/编码 | **MP3(MPEG-1 Layer III)** | iOS Safari / Android Chrome / 微信 webview 全量原生可解码;OGG Vorbis 在 iOS Safari 不被支持,弃用 | +| 码率/声道/采样率 | 48 kbps CBR · 单声道 · 22050 Hz | 纯语音足够,单条约 15–25 KB | +| 响度 | 归一化到 −16 LUFS 左右 | 各条音量一致,不与系统提示音打架 | +| 首尾静音 | ≤150 ms | 提示要"跟手",留白会显迟钝 | +| 音色 | 女声、普通话、语速适中偏缓、亲和 | 与品牌调性一致;三条步骤引导/姿态提示/倒计时必须同一音色 | + +## 2. 播报清单(14 条) + +### A. 步骤引导(进入每个拍照步骤时播,与屏幕步骤文案一致) + +| 文件名 | 文案 | 触发时机 | 时长 | +|---|---|---|---| +| `step1-front.mp3` | 请单人正脸出镜,面部对准取景框 | 进入拍照页 1/3 正脸步 | ≈3s | +| `step2-rightface.mp3` | 请保持单人出镜,慢慢向左转头,露出右脸 | 进入 2/3 右脸出镜步 | ≈4s | +| `step3-leftface.mp3` | 请慢慢向右转头,露出左脸 | 进入 3/3 左脸出镜步 | ≈3s | + +> 语义说明:2/3 步需要画面中看到「右脸」= 用户头向**左**转;3/3 步看到「左脸」= 头向**右**转。语音引导的是用户动作,与取景蒙版方向一致,不要改写成"露出左侧/右侧脸"这类歧义表述。 + +### B. 姿态提示(实时检测不通过时播,同一提示 4 秒内不重复) + +| 文件名 | 文案 | 触发时机(与屏幕提示同文本) | 时长 | +|---|---|---|---| +| `hint-noface.mp3` | 未检测到人脸,请正对镜头 | 画面无人脸 | ≈3s | +| `hint-near.mp3` | 请靠近一点 | 人脸框过高占比 <35%(太远) | ≈1.5s | +| `hint-far.mp3` | 请远一点 | 占比 >80%(太近) | ≈1.5s | +| `hint-front.mp3` | 请正对屏幕 | 正脸步角度不合格 | ≈1.5s | +| `hint-turn-left.mp3` | 请向左转头 | 需要头向左转修正角度时 | ≈1.5s | +| `hint-turn-right.mp3` | 请向右转头 | 需要头向右转修正角度时 | ≈1.5s | +| `hint-hold.mp3` | 保持不动 | 姿态已合格、进入稳定确认 | ≈1.5s | + +### C. 拍摄与流程 + +| 文件名 | 文案 | 触发时机 | 时长 | +|---|---|---|---| +| `countdown-321.mp3` | 三、二、一 | 自动拍摄 3s 倒计时开始(与屏幕大数字同步起播) | ≈2.5s | +| `shot-done.mp3` | 拍摄完成 | 每张照片抓拍成功(白屏补光结束) | ≈1.5s | +| `analyzing.mp3` | 正在生成您的肌肤报告,请稍候 | 三张拍完进入「分析中」浮层 | ≈3s | +| `analyze-fail.mp3` | 检测失败,请调整后重新拍摄 | 检测失败回到档案页时 | ≈3s | + +## 3. 播放策略(前端已实现侧约定) + +1. **清单制**:语音文件配 `assets/voice/manifest.json`(格式 `{"files":["step1-front","hint-near",...]}`,内容为已生成的文件名去 .mp3)。前端只播清单里声明的条目;清单或条目缺失时该条静默跳过——语音文件可分批补齐,每次补齐后更新清单。 +2. **浏览器自动播放限制**:首次播放必须发生在用户手势链路里。前端在任意按钮点击时初始化并 resume WebAudio(点击「开始检测/继续/开始」即完成解锁),后续步骤提示均为程序触发。 +3. **同条去重**:同一文件名 4 秒内不重复播(姿态提示会随检测帧反复触发)。 +4. **不叠加**:新提示开播前停掉上一条未播完的(单通道),倒计时与拍摄完成音除外(允许叠在提示音上)。 +5. **不打断原则**:分析中/失败提示开播时,拍照页已离开,步骤与姿态提示不再触发。 +6. **静音开关**:V1.0 暂不提供 UI 开关,跟随系统音量与静音键;后续如加开关,在拍照页右上角放小喇叭图标,状态存 localStorage。 + +## 4. 生成验收 + +- 每条与第 2 节文案**逐字一致**(语气词/标点可按语感微调,但不得改变指令方向,尤其 2/3、3/3 步的转头方向)。 +- 文件名与第 2 节**完全一致**(小写、连字符、.mp3),放入 `h5/assets/voice/`。 +- **同步更新 `h5/assets/voice/manifest.json`**:`{"files":[已生成的文件名(不含 .mp3)]}`,例如只生成了 3 条就写 3 条;前端只播清单内的条目。 +- 抽验:真机微信内打开,进入拍照页能听到 `step1-front`,转头时能听到 `hint-turn-*`。 diff --git a/h5/css/style.css b/h5/css/style.css index c40f120..a41e00c 100644 --- a/h5/css/style.css +++ b/h5/css/style.css @@ -570,3 +570,27 @@ button { font-family: inherit; border: 0; background: none; cursor: pointer; } .policy-body li::marker { color: var(--grey); } .policy-toc { color: var(--grey-2); } .policy-end { text-align: center; color: var(--grey); margin-top: 28px; } + +/* ═══════════ 历史报告 ═══════════ */ +.nav-history { + position: absolute; right: 10px; top: 50%; transform: translateY(-50%); + font-size: 12px; color: var(--grey-2); padding: 4px 8px; +} +.policy-header .history-clear { + position: absolute; right: 10px; top: 50%; transform: translateY(-50%); + font-size: 12px; color: var(--grey-2); padding: 4px 8px; +} +.history-empty { text-align: center; color: var(--grey); font-size: 12.5px; padding: 60px 0; } +.his-item { + display: flex; align-items: center; gap: 12px; + background: #fff; border: 1px solid var(--line); border-radius: 10px; + padding: 14px 14px; margin-bottom: 10px; cursor: pointer; +} +.his-thumb { width: 44px; height: 56px; border-radius: 6px; object-fit: cover; flex: none; background: var(--bg); } +.his-main { flex: 1; min-width: 0; } +.his-main b { display: block; font-size: 13px; font-weight: 600; color: var(--ink); margin-bottom: 3px; } +.his-main span { display: block; font-size: 11px; color: var(--grey); } +.his-score { flex: none; text-align: right; } +.his-score b { font-size: 22px; font-weight: 500; color: var(--ink); } +.his-score span { font-size: 11px; color: var(--grey); margin-left: 2px; } +.his-del { flex: none; font-size: 11px; color: var(--grey-2); padding: 6px 4px; } diff --git a/h5/index.html b/h5/index.html index 382d6d8..45cbe50 100644 --- a/h5/index.html +++ b/h5/index.html @@ -121,6 +121,7 @@
+
1'me在线肌肤测试 +

您的肌肤状态表现

@@ -436,6 +438,18 @@
+ +
+
+
+ + 历史报告 + +
+
+
+
+
diff --git a/h5/js/app.js b/h5/js/app.js index c4aedef..df39e6a 100644 --- a/h5/js/app.js +++ b/h5/js/app.js @@ -153,7 +153,7 @@ /* ─── 实时相机(getUserMedia)+ 系统相机降级 ─── */ var IS_MOCK = /[?&]mock=1/.test(location.search); // mock:不加载 face-api、不跑检测,analyze 直返内置样本 - var camVideo = $('#cam-video'), camFile = $('#cam-file'); + 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]左脸(头右转) @@ -227,15 +227,11 @@ ctx.drawImage(camVideo, 0, 0, cw, ch); return canvas.toDataURL('image/jpeg', 0.88); } - // 降级模式:快门调起系统相机,拍完/选完在 change 里推进流程 - camFile.addEventListener('change', function () { - shutterLock = false; // 选完(含空选择)即解锁;推进窗口由 onPhotoCaptured 重新上锁 - var f = camFile.files && camFile.files[0]; - camFile.value = ''; + // 文件 → 统一压成最长边 1280 的 JPEG → 推进流程(降级系统相机与相册选图共用) + function readPhotoFile(f) { if (!f) return; var reader = new FileReader(); reader.onload = function () { - // 统一压成最长边 1280 的 JPEG var img = new Image(); img.onload = function () { var scale = Math.min(1, 1280 / Math.max(img.width, img.height)); @@ -247,9 +243,69 @@ img.src = reader.result; }; reader.readAsDataURL(f); + } + // 降级模式:快门调起系统相机,拍完/选完在 change 里推进流程 + camFile.addEventListener('change', function () { + shutterLock = false; // 选完(含空选择)即解锁;推进窗口由 onPhotoCaptured 重新上锁 + var f = camFile.files && camFile.files[0]; + camFile.value = ''; + readPhotoFile(f); }); // 部分系统取消相机/相册只发 cancel 不发 change:兜底解锁,防快门卡死 camFile.addEventListener('cancel', function () { shutterLock = false; }); + // 相册选图(相机页「相册」入口):选当前步的图片,与实拍同管线推进 + albumFile.addEventListener('change', function () { + shutterLock = false; + var f = albumFile.files && albumFile.files[0]; + albumFile.value = ''; + readPhotoFile(f); + }); + albumFile.addEventListener('cancel', function () { shutterLock = false; }); + + /* ─── 语音播报(WebAudio 单通道;assets/voice/.mp3,缺失静默跳过;同条 4s 去重) + 文案/文件名契约:docs/语音播报内容设计文档_V1.0.md ─── */ + var VOICE = { + step: ['step1-front', 'step2-rightface', 'step3-leftface'], + hint: { + '未检测到人脸': 'hint-noface', '请靠近一点': 'hint-near', '请远一点': 'hint-far', + '请正对屏幕': 'hint-front', '请向左转头': 'hint-turn-left', '请向右转头': 'hint-turn-right', + '保持不动': 'hint-hold' + }, + countdown: 'countdown-321', shot: 'shot-done', analyzing: 'analyzing', fail: 'analyze-fail' + }; + var voice = (function () { + var ctx = null, lastAt = {}, cur = null, files = null; // files:null=清单未加载;{}=无可用语音 + // 清单制:只播 assets/voice/manifest.json 里声明的文件,不逐个探文件(避免控制台 404 噪音) + fetch('assets/voice/manifest.json') + .then(function (r) { return r.ok ? r.json() : { files: [] }; }) + .then(function (m) { files = {}; (m.files || []).forEach(function (f) { files[f] = 1; }); }) + .catch(function () { files = {}; }); + function unlock() { // iOS/微信自动播放限制:必须在用户手势里初始化并 resume + if (!ctx) { try { ctx = new (window.AudioContext || window.webkitAudioContext)(); } catch (e) { ctx = null; } } + if (ctx && ctx.state === 'suspended') ctx.resume(); + } + function play(id) { + if (!ctx || !id || ctx.state !== 'running' || !files || !files[id]) return; + var now = Date.now(); + if (lastAt[id] && now - lastAt[id] < 4000) return; + lastAt[id] = now; + fetch('assets/voice/' + id + '.mp3') + .then(function (r) { return r.ok ? r.arrayBuffer() : null; }) + .then(function (ab) { + 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); + }, function () { /* 解码失败忽略 */ }); + }) + .catch(function () { /* 网络异常忽略 */ }); + } + return { unlock: unlock, play: play }; + })(); + document.addEventListener('click', voice.unlock); // 任意点击即完成解锁 /* ─── 姿态检测(face-api + HeadPose,引导式自动三连拍) ─── */ var MODEL_URL = 'vendor/faceapi/models'; @@ -287,7 +343,10 @@ function setHint(text, ok) { var h = $('#cam-hint'); if (!text) { h.classList.remove('show', 'ok'); lastHint = ''; return; } - if (text !== lastHint) h.textContent = text; // 同文案不重复触发布局 + if (text !== lastHint) { + h.textContent = text; // 同文案不重复触发布局 + voice.play(VOICE.hint[text]); // 语音同步(模块内 4s 去重) + } h.classList.add('show'); h.classList.toggle('ok', !!ok); lastHint = text; @@ -326,6 +385,7 @@ c.classList.add('show'); setMask('ok'); // 轮廓保持绿色 setHint('保持不动', true); + voice.play(VOICE.countdown); // 「三、二、一」与数字同步起播 cdTimer = setTimeout(cdTick, 1000); } function cdTick() { @@ -430,6 +490,7 @@ } $('#cam-mask').setAttribute('data-step', camStep); // 蒙版按步骤切换 resetDetectState(); + voice.play(VOICE.step[camStep]); // 步骤引导语音 } var camSeq = 0; // 流程令牌:cam-back/开始拍照时递增,使未决的快门/分析回调失效 @@ -440,6 +501,7 @@ shutterLock = true; // 推进窗口上锁:降级模式 change 里已解锁,这里重新锁住直到下一步/送检 photos[camStep] = dataUrl; $('#cam-dots').children[camStep].classList.add('done'); + voice.play(VOICE.shot); // 「拍摄完成」 var seq = camSeq; setTimeout(function () { if (seq !== camSeq) return; // 期间已 cam-back/重新开始:放弃本次推进 @@ -482,6 +544,7 @@ function submitPhotos(seq) { camStep = 0; openOverlay('p-analyzing'); + voice.play(VOICE.analyzing); // 「正在生成您的肌肤报告」 var t0 = Date.now(); requestAnalyze(photos, seq, function (errMsg, payload) { if (seq !== camSeq) return; // 流程已被重置:丢弃 @@ -492,15 +555,22 @@ stopCamera(); shutterLock = false; // 流程结束解锁 if (errMsg) { + voice.play(VOICE.fail); toast(errMsg); showScreen('s-profile'); // 回档案页,允许重新提交再拍 return; } var vm = null; + var profile = { + age: +($('#f-age').value || 0) || null, + gender: $('#f-gender').value || '', + region: regionSel.value || '', + skinType: SKINS[skinIndex].name + }; try { vm = SkinScore.buildViewModel(payload.result, payload.side, { - realAge: +($('#f-age').value || 0) || null, - skinType: SKINS[skinIndex].name + realAge: profile.age, + skinType: profile.skinType }); } catch (e) { vm = null; } if (!vm) { @@ -509,6 +579,7 @@ return; } renderReport(vm, photos[0], photos[1], payload); // img1=正脸实拍,payload 供检测可视化取 face_maps/marks + storeReport(payload, photos, profile); // 以检测为单位存三张照片+分析结果(本地历史) photos = []; // 报告已渲染(图片 src 已挂):释放三连拍 dataURL 内存 showScreen('s-report'); }, wait); @@ -905,9 +976,19 @@ else { stopCamera(); showScreen('s-profile'); } }, 'shutter': shutter, - 'album': function () { toast('相册功能开发中'); }, + 'album': function () { + shutterLock = true; // 与降级快门同策略:前置上锁,change/cancel 解锁 + albumFile.click(); + }, 'flip': function () { toast('切换镜头功能开发中'); }, 'back-profile': function () { showScreen('s-profile'); }, + 'history': function () { openHistory(); }, + 'close-history': function () { closeOverlay('p-history'); }, + 'clear-history': function () { + saveReports([]); + renderHistoryList(); + toast('历史报告已清空'); + }, 'more': function () { toast('了解更多功能开发中'); } }; @@ -918,6 +999,84 @@ if (fn) fn(); }); + /* ─── 报告历史(localStorage):以检测为单位,存三张照片+分析结果(数据/坐标/文案), + 不含 face_maps 整幅图层(体积原因);上限 5 条,超配额逐条淘汰 ─── */ + var REPORTS_KEY = 'juzi.reports.v1', REPORTS_MAX = 5; + function loadReports() { + try { var v = JSON.parse(localStorage.getItem(REPORTS_KEY)); return Array.isArray(v) ? v : []; } + catch (e) { return []; } + } + function saveReports(list) { + try { localStorage.setItem(REPORTS_KEY, JSON.stringify(list)); return true; } + catch (e) { return false; } // QuotaExceeded:交给调用方淘汰重试 + } + function storeReport(payload, ph, profile) { + if (!payload || !payload.result) return; + var result = Object.assign({}, payload.result); + delete result.face_maps; // 历史不存图层;可视化中图层类(皱纹/肤色/敏感)回看时降级为占位,坐标叠加类(光滑/痘痘)仍可用 + var entry = { + id: 'r' + Date.now(), ts: Date.now(), profile: profile, + total: payload.result.score_info && payload.result.score_info.total_score, + skinAge: payload.result.skin_age && payload.result.skin_age.value, + photos: [ph[0] || null, ph[1] || null, ph[2] || null], + payload: { result: result, side: payload.side || null, face_rectangle: payload.face_rectangle || null } + }; + var list = loadReports(); + list.unshift(entry); + while (list.length > REPORTS_MAX) list.pop(); + while (!saveReports(list) && list.length > 1) list.pop(); // 淘汰至可写 + saveReports(list); + } + function fmtTime(ts) { + var d = new Date(ts); + function p(n) { return (n < 10 ? '0' : '') + n; } + return d.getFullYear() + '/' + (d.getMonth() + 1) + '/' + d.getDate() + ' ' + p(d.getHours()) + ':' + p(d.getMinutes()); + } + function renderHistoryList() { + var box = $('#history-list'), list = loadReports(); + if (!list.length) { + box.innerHTML = '

暂无历史报告
完成一次肌肤检测后会自动保存在这里

'; + return; + } + box.innerHTML = list.map(function (e) { + var sub = [e.profile && e.profile.age ? e.profile.age + '岁' : '', e.profile && e.profile.skinType || ''] + .filter(Boolean).join(' · '); + var score = e.total != null ? e.total : '--'; + var img = e.photos && e.photos[0] ? '' : ''; + return '
' + img + + '
' + fmtTime(e.ts) + '' + sub + ' · 肌龄' + (e.skinAge != null ? e.skinAge : '--') + '岁
' + + '
' + score + '分
' + + '
'; + }).join(''); + } + function openHistory() { + renderHistoryList(); + openOverlay('p-history'); + } + $('#history-list').addEventListener('click', function (e) { + var del = e.target.closest('[data-del]'); + if (del) { // 删除单条(不打开报告) + e.stopPropagation(); + saveReports(loadReports().filter(function (it) { return it.id !== del.dataset.del; })); + renderHistoryList(); + return; + } + var item = e.target.closest('.his-item'); + if (!item) return; + var entry = loadReports().filter(function (it) { return it.id === item.dataset.id; })[0]; + if (!entry) return; + var vm = null; + try { + vm = SkinScore.buildViewModel(entry.payload.result, entry.payload.side, { + realAge: entry.profile && entry.profile.age, skinType: entry.profile && entry.profile.skinType + }); + } catch (err) { vm = null; } + if (!vm) { toast('该记录已损坏'); return; } + renderReport(vm, entry.photos && entry.photos[0], null, entry.payload); + closeOverlay('p-history'); + showScreen('s-report'); + }); + /* ─── 页面隐藏/来电生命周期:隐藏即停相机与全部定时器,回前台从当前步恢复(保留 photos[] 与 camStep) ─── */ function handleCamHidden() { if (currentScreen !== 's-cam') return; diff --git a/h5/server.js b/h5/server.js index 1cf13e4..a87258c 100644 --- a/h5/server.js +++ b/h5/server.js @@ -15,6 +15,7 @@ const path = require('path'); const PORT = Number(process.env.PORT) || 8080; const ROOT = __dirname; const FACEPP_URL = 'https://api-cn.faceplusplus.com/facepp/v1/skinanalyze_pro'; +const DATA_DIR = path.join(ROOT, 'data', 'results'); // 每次检测的原始返回落盘(复核用,已在 .gitignore) /* ─── 密钥加载:环境变量优先,其次 apikey.bash,最后 reference 下的 credentials.env ─── */ function loadCredentials() { @@ -185,6 +186,15 @@ async function handleAnalyze(req, res) { return res.end(JSON.stringify({ ok: false, code: 'INTERNAL_ERROR', message: ERR_MSG.INTERNAL_ERROR })); } const result = fpJson.result; + // 原始返回完整落盘(含 face_maps base64),供复核/真值比对;写盘失败不影响检测响应 + try { + fs.mkdirSync(DATA_DIR, { recursive: true }); + const reqId = String(fpJson.request_id || 'noreq').replace(/[^\w,-]/g, '').slice(0, 40); + const stamp = new Date().toISOString().replace(/[:.]/g, '').slice(0, 15); + const fname = `${stamp}-${reqId}.json`; + fs.writeFile(path.join(DATA_DIR, fname), JSON.stringify(fpJson), () => {}); + console.log(`[analyze] 原始返回已落盘: data/results/${fname}`); + } catch (e) { console.log(`[analyze] 落盘失败(忽略): ${e.message}`); } // face_maps 是整幅 base64 叠加图,体积很大:只透传报告页所需图层(REPORT_MAPS), // 值补 data: URL 前缀供前端 直接用;未选中的图层直接删除键; // marks 坐标字段(acne/brown_spot/mole/closed_comedones/wrinkle_mark 等)本就在 result 里,原样透传