This commit is contained in:
Ubuntu
2026-09-26 23:55:11 +08:00
parent 859d262fe3
commit 7d72d7dd5a
6 changed files with 282 additions and 11 deletions
+1
View File
@@ -11,3 +11,4 @@ node_modules/
*.log
__pycache__/
aipkey.bash
h5/data/
@@ -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-*`。
+24
View File
@@ -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; }
+14
View File
@@ -121,6 +121,7 @@
<div class="cam-guide" id="cam-guide"></div>
<video id="cam-video" autoplay playsinline muted></video>
<input type="file" id="cam-file" accept="image/*" capture="user" hidden>
<input type="file" id="album-file" accept="image/*" hidden> <!-- 相册选图(不带 capture:走图库而非调起相机) -->
<div class="cam-controls">
<button class="cam-side" data-action="album" aria-label="相册">
<img src="assets/svg/图标-相册.svg" alt="相册">
@@ -140,6 +141,7 @@
<header class="nav">
<button class="nav-back" data-action="back-profile" aria-label="返回"><i class="chev left"></i></button>
<span class="nav-title">1'me在线肌肤测试</span>
<button class="nav-history" data-action="history">历史</button>
</header>
<div class="report-body">
<h1 class="report-title">您的肌肤状态表现</h1>
@@ -436,6 +438,18 @@
</div>
</div>
<!-- ═══════════ 弹层:历史报告(localStorage 存储的检测记录) ═══════════ -->
<div class="overlay overlay-policy" id="p-history">
<div class="policy-page">
<header class="policy-header">
<button class="nav-back" data-action="close-history" aria-label="返回"><i class="chev left"></i></button>
<span class="policy-header-title">历史报告</span>
<button class="history-clear" data-action="clear-history">清空</button>
</header>
<div class="policy-body history-body" id="history-list"></div>
</div>
</div>
<!-- ═══════════ 分析中过渡 ═══════════ -->
<div class="overlay overlay-dark" id="p-analyzing">
<div class="analyzing">
+170 -11
View File
@@ -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/<id>.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 = '<p class="history-empty">暂无历史报告<br>完成一次肌肤检测后会自动保存在这里</p>';
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] ? '<img class="his-thumb" src="' + e.photos[0] + '" alt="">' : '<i class="his-thumb"></i>';
return '<div class="his-item" data-id="' + e.id + '">' + img +
'<div class="his-main"><b>' + fmtTime(e.ts) + '</b><span>' + sub + ' · 肌龄' + (e.skinAge != null ? e.skinAge : '--') + '岁</span></div>' +
'<div class="his-score"><b>' + score + '</b><span>分</span></div>' +
'<button class="his-del" data-del="' + e.id + '">删除</button></div>';
}).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;
+10
View File
@@ -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 前缀供前端 <img> 直接用;未选中的图层直接删除键;
// marks 坐标字段(acne/brown_spot/mole/closed_comedones/wrinkle_mark 等)本就在 result 里,原样透传