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 @@
+
+
+
+
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 里,原样透传