fix: 拍照页对齐 Figma(正→右→左/镜像预览/WebView 口径定位/85%遮罩) + 语音调度重写 + 正脸照不镜像
- 拍照顺序改为正脸→右脸→左脸(对齐 Figma),左右以用户本人为准;送检前按 target 重排为 [正,左,右] - 三步预览均镜像(Figma 侧脸轮廓按镜像绘制),后置镜头侧脸轮廓自动翻转;抓帧恒为原始视角 - 蒙版 viewBox 取 Figma 帧 y86–812(扣除微信导航栏)+ meet 缩放,文案/引导/提示按 726 高定位;框外 85% 黑,静态态白底 - 语音:流程音排队不截断、姿态提示挂起补播、倒计时等播报收尾、拍摄完成打断过时引导、出报告/退出清队列;去重仅限姿态提示 - 语音文件改名 step2-rightface / step3-leftface,同步清单、生成脚本与设计文档 - 精华卡去重复正文、推荐依据写明具体原因;年龄仅数字;toast 按字数延时;备注去内部质量码 - 新增无头浏览器冒烟测试 h5/test/e2e-browser.mjs Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+10
-7
@@ -20,18 +20,18 @@
|
||||
| 文件名 | 文案 | 触发时机 | 时长 |
|
||||
|---|---|---|---|
|
||||
| `step1-front.mp3` | 请单人正脸出镜,面部对准取景框 | 进入拍照页 1/3 正脸步 | ≈3s |
|
||||
| `step2-leftface.mp3` | 请慢慢向右转头,露出左脸 | 进入 2/3 左脸出镜步 | ≈3s |
|
||||
| `step3-rightface.mp3` | 请保持单人出镜,慢慢向左转头,露出右脸 | 进入 3/3 右脸出镜步 | ≈4s |
|
||||
| `step2-rightface.mp3` | 请保持单人出镜,慢慢向左转头,露出右脸 | 进入 2/3 右脸出镜步 | ≈4s |
|
||||
| `step3-leftface.mp3` | 请慢慢向右转头,露出左脸 | 进入 3/3 左脸出镜步 | ≈3s |
|
||||
|
||||
> 语义说明:拍摄顺序为正脸 → 左脸 → 右脸。2/3 步需要画面中看到「左脸」= 用户头向**右**转;3/3 步看到「右脸」= 头向**左**转。语音引导的是用户动作,与取景蒙版方向一致,不要改写成"露出左侧/右侧脸"这类歧义表述。
|
||||
> 语义说明:拍摄顺序对齐 Figma,为正脸 → 右脸 → 左脸。左右一律以**用户本人**的左右为准:2/3「右脸」= 用户头向自己的**左**侧转;3/3「左脸」= 头向自己的**右**侧转。前置预览为镜像(照镜子),与取景蒙版方向一致。不要改写成"露出左侧/右侧脸"这类歧义表述。
|
||||
|
||||
### B. 姿态提示(实时检测不通过时播,同一提示 4 秒内不重复)
|
||||
|
||||
| 文件名 | 文案 | 触发时机(与屏幕提示同文本) | 时长 |
|
||||
|---|---|---|---|
|
||||
| `hint-noface.mp3` | 未检测到人脸,请正对镜头 | 画面无人脸 | ≈3s |
|
||||
| `hint-near.mp3` | 请靠近一点 | 人脸框过高占比 <35%(太远) | ≈1.5s |
|
||||
| `hint-far.mp3` | 请远一点 | 占比 >80%(太近) | ≈1.5s |
|
||||
| `hint-near.mp3` | 请靠近一点 | 人脸框高占画面高 <26%(太远) | ≈1.5s |
|
||||
| `hint-far.mp3` | 请远一点 | 占比 >58%(太近) | ≈1.5s |
|
||||
| `hint-front.mp3` | 请正对屏幕 | 正脸步角度不合格(正脸步一律只提示回正,不播转头/抬头低头) | ≈1.5s |
|
||||
| `hint-turn-left.mp3` | 请向左转头 | 仅侧脸步:需要头向左转修正角度时 | ≈1.5s |
|
||||
| `hint-turn-right.mp3` | 请向右转头 | 仅侧脸步:需要头向右转修正角度时 | ≈1.5s |
|
||||
@@ -50,8 +50,11 @@
|
||||
|
||||
1. **清单制**:语音文件配 `assets/voice/manifest.json`(格式 `{"files":["step1-front","hint-near",...]}`,内容为已生成的文件名去 .mp3)。前端只播清单里声明的条目;清单或条目缺失时该条静默跳过——语音文件可分批补齐,每次补齐后更新清单。
|
||||
2. **浏览器自动播放限制**:首次播放必须发生在用户手势链路里。前端在任意按钮点击时初始化并 resume WebAudio(点击「开始检测/继续/开始」即完成解锁),后续步骤提示均为程序触发。
|
||||
3. **同条去重**:同一文件名 4 秒内不重复播(姿态提示会随检测帧反复触发)。
|
||||
4. **不叠加**:新提示开播前停掉上一条未播完的(单通道);例外:倒计时「三、二、一」不在播报中途拦腰切入——姿态达标后先等「保持不动」等在播提示音自然收尾,再同步起播倒计时语音与画面数字(`voice.afterCurrent` 接续,期间姿态被破坏则取消倒计时)。
|
||||
3. **同条去重**:姿态提示同一文件名 4 秒内不重复播(会随检测帧反复触发);流程音是离散事件,每次都播(连拍的「拍摄完成」、取消后重来的倒计时)。
|
||||
4. **不叠加、不截断**:单通道,两类播报分开处理——
|
||||
- **流程音**(A 类步骤引导 + C 类倒计时/拍摄完成/分析中):排队顺序播,不互相截断(如「拍摄完成」播完再播下一步引导)。例外:`analyze-fail` 直接打断「分析中」。
|
||||
- **姿态提示**(B 类):提示之间可互相打断;流程音播放期间到来的提示不截断流程音,只挂起最新一条,流程音收尾后补播;换步或提示清空时挂起作废。
|
||||
- 倒计时「三、二、一」等全部在播/排队/挂起的播报自然收尾后,与画面数字同步起播(`voice.afterCurrent` 接续,期间姿态被破坏则取消倒计时)。
|
||||
5. **不打断原则**:分析中/失败提示开播时,拍照页已离开,步骤与姿态提示不再触发。
|
||||
6. **静音开关**:V1.0 暂不提供 UI 开关,跟随系统音量与静音键;后续如加开关,在拍照页右上角放小喇叭图标,状态存 localStorage。
|
||||
|
||||
|
||||
+16
-14
@@ -51,18 +51,18 @@ node server.js # 默认 8080,可用 PORT=xxx 修改
|
||||
|
||||
## 拍照交互(引导式自动三连拍)
|
||||
|
||||
三步:1/3 正脸(target `front`)→ 2/3 左脸出镜(用户头向右转,target `left`,产物作 `left_image`)→ 3/3 右脸出镜(头向左转,target `right`,产物作 `right_image`)。
|
||||
三步(顺序对齐 Figma,左右一律以用户本人为准):1/3 正脸(target `front`)→ 2/3 右脸出镜(用户头向自己的左侧转,target `right`,产物作 `right_image`)→ 3/3 左脸出镜(头向自己的右侧转,target `left`,产物作 `left_image`)。送检前 `photosByView` 按 target 重排为 [正, 左, 右]。
|
||||
|
||||
- **步骤圆点**:顶部三点,当前步高亮、完成步绿色打勾。
|
||||
- **取景蒙版**:内联 SVG(`#cam-mask`)按步骤切换轮廓——正脸步为正脸剪影(含耳部),侧脸步为对应方向的侧脸剪影(鼻朝向与转头方向一致);**轮廓路径由 Figma 拍照示意图(`design/assets/images/*-拍照示意.png` 的 alpha 镂空区)逐像素描摹生成**,等比缩放到 375×812 viewBox;框外纯黑不透明(SVG mask 镂空,对齐 Figma:只有轮廓内显示实时画面),轮廓线 2.5px 半透明白。未检测到人脸时轮廓呼吸动画(1.8s);姿态 ok 时轮廓变绿并停止动效。
|
||||
- **取景蒙版**:内联 SVG(`#cam-mask`)按步骤切换轮廓——正脸步为正脸剪影(含耳部),侧脸步为对应方向的侧脸剪影(鼻朝向与转头方向一致);**轮廓路径由 Figma 拍照示意图(`design/assets/images/*-拍照示意.png` 的 alpha 镂空区)逐像素描摹生成**,坐标为 Figma 375×812 整帧,viewBox 只取 y 86–812(帧顶 86px 是微信导航栏,H5 视口从其下方开始;步骤文案/引导等纵向百分比也以 726 高为基准);框外 85% 黑(SVG mask 镂空,对齐 Figma 图层不透明度),轮廓线 2.5px 半透明白。未检测到人脸时轮廓呼吸动画(1.8s);姿态 ok 时轮廓变绿并停止动效。
|
||||
- **正脸步提示口径**:正脸步角度不合格一律只提示「请正对屏幕」(不再提示左右转头/抬头低头),阈值 |yaw|≤15°、|pitch|≤18°(2026-09-28 产品要求放宽);侧脸步仍按方向提示「请向左/右转头」。
|
||||
- **实时检测**:face-api 从本地 `vendor/faceapi/` 加载(`tinyFaceDetector` + `faceLandmark68Net`)。进入拍照页即开始加载模型(状态提示"检测引擎加载中…"),加载完成前快门为手动模式。检测循环 ~150ms 一帧,`detectSingleFace(video, TinyFaceDetectorOptions({inputSize:416, scoreThreshold:0.3})).withFaceLandmarks()` 后拿 68 特征点调 `HeadPose.check(pos, target, mirrored)`——mirrored 正脸步跟随镜头(前置 true/后置 false),侧脸步恒 false(预览不镜像,特征点即原始视角;个别前置帧已镜像的机型由侧脸自校准翻转 `sideYawFlip`,只动判定不动画面);连续漏检 ≥3 帧才报"未检测到人脸"(容忍单帧抖动);人脸框高度占比 <26% 提示"请靠近一点"、>58% 提示"请远一点"(以取景框≈42% 屏高标定)。提示一次一条,优先级:无人脸 > 距离 > 角度。
|
||||
- **实时检测**:face-api 从本地 `vendor/faceapi/` 加载(`tinyFaceDetector` + `faceLandmark68Net`)。进入拍照页即开始加载模型(状态提示"检测引擎加载中…"),加载完成前快门为手动模式。检测循环 ~150ms 一帧,`detectSingleFace(video, TinyFaceDetectorOptions({inputSize:416, scoreThreshold:0.3})).withFaceLandmarks()` 后拿 68 特征点调 `HeadPose.check(pos, target, mirrored)`——特征点取自原始帧(预览 CSS 镜像不影响),侧脸步 mirrored 恒 false(个别前置帧已镜像的机型由侧脸自校准翻转 `sideYawFlip`,只动判定不动画面),正脸步只看 |yaw|;连续漏检 ≥3 帧才报"未检测到人脸"(容忍单帧抖动);人脸框高度占比 <26% 提示"请靠近一点"、>58% 提示"请远一点"(以取景框≈42% 屏高标定)。提示一次一条,优先级:无人脸 > 距离 > 角度。
|
||||
- **倒计时自动拍**:姿态 ok 连续维持 ≥600ms 后,先等「保持不动」等在播提示音自然收尾(画面保持绿框+保持不动),再进 3s 倒计时(中央大数字,轮廓保持绿色,数字与「三、二、一」语音同步起播,不再拦腰切断);倒计时中任一帧不 ok 立即取消并回到对应提示。倒计时结束进入白屏补光。
|
||||
- **镜像策略**:正脸步前置镜像(自拍习惯),侧脸步预览与抓帧均不镜像(所见即摄像头原始视角,轮廓鼻朝向与转头方向一致,左右不再迷惑);后置镜头全程不镜像。
|
||||
- **白屏补光**:全屏纯白覆盖层瞬间点亮,~300ms 后抓全分辨率帧(与预览同镜像策略 + 最长边 1280 JPEG),保持 ~300ms 再淡出(总 600–900ms),同步 `navigator.vibrate(30)`。
|
||||
- **镜像策略**:前置镜头三步**预览**都镜像(照镜子习惯;Figma 侧脸轮廓即按镜像画面绘制:右脸出镜耳在画面右、左脸出镜耳在画面左);后置镜头预览不镜像,侧脸轮廓随之水平翻转(`.cam-mask.flip`)。**抓帧一律不镜像**:三张照片都是摄像头原始视角,Face++ 左右分区文案(左嘴角纹/左右脸颊)对应用户真实左右,与真值样本同口径。
|
||||
- **白屏补光**:全屏纯白覆盖层瞬间点亮,~300ms 后抓全分辨率帧(原始视角 + 最长边 1280 JPEG),保持 ~300ms 再淡出(总 600–900ms),同步 `navigator.vibrate(30)`。
|
||||
- **手动兜底**:快门任何时刻可拍当前步(同样走白屏补光),不依赖检测;face-api/模型加载失败 → toast"自动识别不可用,已切换手动拍摄",全程手动。系统相机降级模式(getUserMedia 不可用/被拒)维持原样:无检测、无补光。
|
||||
- **相册选图**:相机页「相册」按钮可选当前步的图片(任何模式下可用),与实拍同管线(压 1280 → 推进流程)。
|
||||
- **语音播报**:步骤引导/姿态提示/倒计时/拍摄完成/分析中/失败共 14 条,文案与文件名契约见 `../docs/语音播报内容设计文档_V1.0.md`。WebAudio 单通道播放、同条 4s 去重;`assets/voice/manifest.json` 清单制,**语音文件待生成**——清单未提供时全部静默跳过。
|
||||
- **语音播报**:步骤引导/姿态提示/倒计时/拍摄完成/分析中/失败共 14 条,文案与文件名契约见 `../docs/语音播报内容设计文档_V1.0.md`。WebAudio 单通道、姿态提示同条 4s 去重、解锁后预加载全部条目;流程音(步骤/拍摄完成/倒计时/分析中)排队顺序播放互不截断,姿态提示不截断流程音(期间只挂起最新一条,收尾后补播),失败提示可打断「分析中」。`assets/voice/manifest.json` 清单制(14 条已生成),清单缺失的条目静默跳过。
|
||||
- **无进展提示**:单步 12s 未触发倒计时,底部浮出"识别不到?可点下方快门手动拍摄"(不阻塞流程)。
|
||||
- **资源管理**:离开拍照页(cam-back/完成)停止检测循环、清全部定时器、释放摄像头;`camSeq`(流程)/`camSeqCam`(相机)双令牌防竞态;face-api 模型只加载一次(缓存 promise,失败后下次进入重试)。
|
||||
|
||||
@@ -70,7 +70,7 @@ node server.js # 默认 8080,可用 PORT=xxx 修改
|
||||
|
||||
1. 三视图送检:POST `/api/skin-analyze`,请求 `{ front_image, left_image, right_image }`(base64 可带 dataURL 前缀;正脸=photos[0],左=photos[2],右=photos[1])。
|
||||
2. 服务端转发 Face++ **专业版 `skinanalyze_pro`**(三视图必须走 `image_file` 文件形式),请求 `return_side_results=jawline_info` 取左右下颌线角度,`return_maps`/`return_marks` 取叠加图与标注;响应 `{ ok:true, result, face_rectangle, side:{ left, right } }`,失败 `{ ok:false, code, message }`(message 已是用户可读文案)。`result.face_maps` 为对象,含报告页所需图层的完整 data URL(`red_area`/`brown_area`/`texture_enhanced_lines`/`texture_enhanced_pores`/`texture_enhanced_blackheads`,由 server.js 的 `REPORT_MAPS` 常量控制);`marks` 坐标(`acne`/`brown_spot`/`mole`/`closed_comedones`/`acne_pustule`/`acne_nodule`/`wrinkle_mark`/`melanin_mark`/`dark_circle_mark`)随 `result` 原样透传,与上传图片同一像素空间。
|
||||
3. 评分由 `js/skin-score.js` 引擎完成,公式/锁值/话术/产品推荐以 `docs/三维度评分与话术生成需求文档_V2.2.docx` 为最高契约(V2.2 口径):
|
||||
3. 评分由 `js/skin-score.js` 引擎完成,公式/锁值/话术/产品推荐以 `docs/三维度评分与话术生成需求文档_V2.3.docx` 为最高契约(V2.3 口径,含 §8.5 护理建议五段):
|
||||
- 6 分类:轮廓 = min(眼袋映射分, 下颌线角度分);皱纹(12 分区);光滑(毛孔/黑头/纹理);肤色水润(色素×水润);敏感(泛红面积取 `red_spot.red_spot_area`/红区分);痘痘(分级映射×丘疹 `acne.count` 计数扣分);分组按实际年龄;
|
||||
- 三维度木桶 min:紧塑力(轮廓+皱纹)、活力感(光滑+肤色水润)、稳定力(敏感+痘痘);
|
||||
- S = 7 + 3×P/100;档位:良好(P≥90)/改善(80–90)/预警(<80);肤龄钳制到实际年龄 ±3 并留痕;三视图版 18 条话术;
|
||||
@@ -78,12 +78,13 @@ node server.js # 默认 8080,可用 PORT=xxx 修改
|
||||
4. 前端 `SkinScore.buildViewModel(data.result, data.side, { realAge, skinType })` → viewModel 直渲报告页:
|
||||
- 头部:综合评分(`vm.total`,缺失回退三维度 S 均分×10)、肤龄 `vm.skinAgeDisplay` + 钳制标记 `vm.ageClampNote`;三维度 S(1 位小数)+ 档位配色(良好绿/改善琥珀/预警红);
|
||||
- 三个 Tab 面板各展示所辖两分类:分类名、S + 档位徽章、话术正文(`vm.cats.*.text`);P 值不直接展示,`P=null` 的分类显示"本次未检出有效数据";
|
||||
- 报告底部:降级标注(`vm.notes` 非空才展示,如下颌线未参与评分)、综合分析(`vm.summary`)、生活方式建议(`vm.lifestyle`)、精华推荐卡(`vm.essence` + 触发判据);
|
||||
- 报告底部:降级标注(`vm.notes` 中与侧脸/下颌线/补拍相关的条目,去掉接口质量码后展示)、护理建议五段(`vm.careAdvice`)、精华推荐卡(`vm.essence.name` + 命中判据;推荐正文已在护理建议第 4 段,卡内不重复);
|
||||
- 近期优先关注 = 最差维度(`vm.focus`);6 分类各配检测可视化图:轮廓=正脸实拍、皱纹=正脸压暗+`texture_enhanced_lines` 皱纹增强线(线图层几乎全透明,直挂白底不可读,故与压暗沉脸合成)、光滑=正脸+色斑/痣/痤疮标记叠加、肤色=`brown_area` 色沉图、敏感=`red_area` 红区图、痘痘=正脸+闭口粉刺/痤疮标记叠加(marks 按原像素坐标叠加到正脸实拍)。
|
||||
5. 真值回归:`node h5/test/regression.js` 以 `docs/皮肤检测测试结果_三视图_20260926.xlsx`(sheet「三视图检测汇总」,数据第 3/4 行=案例1/案例2)两组三视图真值对照评分引擎输出。
|
||||
6. **报告历史**:检测成功后按「检测」为单位存入 localStorage(`juzi.reports.v1`,上限 5 条,超配额逐条淘汰)——含三张照片、档案、全部评分数据与 marks 坐标,**不含 face_maps 整幅图层**(体积原因;回看时皱纹/肤色/敏感图降级为占位,光滑/痘痘标记叠加仍可用)。当前无 UI 入口(第9轮移除),数据仅作本地追溯。
|
||||
7. **原始返回落盘**:服务端每次检测成功后把 Face++ 完整原始返回写入 `h5/data/results/<时间戳>-<request_id>.json`(复核/真值用,已 .gitignore;写盘失败不影响响应)。
|
||||
8. 检测失败按服务端 message 提示并退回档案页。
|
||||
5. 浏览器冒烟:`node h5/test/e2e-browser.mjs`(需全局 `playwright` + `npx playwright install chromium`,服务跑在 8080)——mock 三连拍到报告页,并核对 14 条语音可拉取、流程语音完整播完不被截断。
|
||||
6. 真值回归:`node h5/test/regression.js` 以 `docs/皮肤检测测试结果_三视图_20260926.xlsx`(sheet「三视图检测汇总」,数据第 3/4 行=案例1/案例2)两组三视图真值对照评分引擎输出。
|
||||
7. **报告历史**:检测成功后按「检测」为单位存入 localStorage(`juzi.reports.v1`,上限 5 条,超配额逐条淘汰)——含三张照片、档案、全部评分数据与 marks 坐标,**不含 face_maps 整幅图层**(体积原因;回看时皱纹/肤色/敏感图降级为占位,光滑/痘痘标记叠加仍可用)。当前无 UI 入口(第9轮移除),数据仅作本地追溯。
|
||||
8. **原始返回落盘**:服务端每次检测成功后把 Face++ 完整原始返回写入 `h5/data/results/<时间戳>-<request_id>.json`(复核/真值用,已 .gitignore;写盘失败不影响响应)。
|
||||
9. 检测失败按服务端 message 提示并退回档案页。
|
||||
|
||||
## 部署
|
||||
|
||||
@@ -129,8 +130,9 @@ server {
|
||||
## 已知占位清单
|
||||
|
||||
- 产品图(prod-1.jpg)为设计稿素材;检测图已替换为用户实拍。
|
||||
- **语音文件待生成**:播报文案/文件名/格式已定(`../docs/语音播报内容设计文档_V1.0.md`),生成后放入 `assets/voice/` 并更新 `manifest.json` 即生效。
|
||||
- 切换镜头、「了解更多」为开发中占位(Toast 提示)。
|
||||
- 语音 14 条已由 `tools/gen_voice.py` 生成;改文案后重跑脚本并同步 `manifest.json`。
|
||||
- 「了解更多」为开发中占位(Toast 提示);拍照页右下按钮为切换前后镜头(系统相机降级模式下提示在系统相机内切换)。
|
||||
- 产品推荐卡(COREⅡ 双仓精华液)为设计稿固定素材,不随精华推荐档位(25+/35+/50+)变化,待产品提供三档对应素材。
|
||||
- **姿态与距离参数已用模拟摄像头校准**(2026-09-27):以 C3 三视图(+45° 侧脸)生成 Y4M 模拟摄像头视频,闭环修正了 yaw 增益(2.2→3.8)、pitch 基线(0.32→0.45)、check() 双重镜像符号 bug、距离阈值(0.35/0.8→0.26/0.58)、检测参数(320/0.4→416/0.3)与漏检 3 帧容忍;自动三连拍在无头浏览器全程通过(e2e E7)。**真机仍建议复核一次**:不同机型前置镜像策略/取景比例可能微调体验(校准接口:调用方 `mirrored` 标志与 `HeadPose.config`)。
|
||||
|
||||
## 技术说明
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"files": [
|
||||
"step1-front",
|
||||
"step2-leftface",
|
||||
"step3-rightface",
|
||||
"step2-rightface",
|
||||
"step3-leftface",
|
||||
"hint-noface",
|
||||
"hint-near",
|
||||
"hint-far",
|
||||
|
||||
+14
-14
@@ -198,10 +198,8 @@ button { font-family: inherit; border: 0; background: none; cursor: pointer; }
|
||||
|
||||
/* ═══════════ 05-07 拍照页 ═══════════ */
|
||||
#s-cam { background: #000; }
|
||||
.cam-bg {
|
||||
position: absolute; inset: 0;
|
||||
background-size: cover; background-position: center;
|
||||
}
|
||||
/* 相机未就绪/降级时的静态态:白底经蒙版 85% 压暗 → 框外深灰、轮廓内白,即 Figma 05-07 静态稿 */
|
||||
.cam-bg { position: absolute; inset: 0; background: #fff; }
|
||||
/* 实时相机画面(getUserMedia 成功后显示,盖住静态示意图) */
|
||||
#cam-video {
|
||||
position: absolute; inset: 0;
|
||||
@@ -212,11 +210,12 @@ button { font-family: inherit; border: 0; background: none; cursor: pointer; }
|
||||
}
|
||||
#s-cam.cam-live #cam-video { display: block; }
|
||||
#s-cam.cam-live .cam-bg { display: none; }
|
||||
/* 设计稿(Figma 05-07):取景框外全黑,仅轮廓镂空区显示实时画面,不再整屏压暗 */
|
||||
/* 设计稿(Figma 05-07):压暗只在取景蒙版框外(.cam-dim-rect),不再整屏压暗 */
|
||||
#s-cam::before { content: none; }
|
||||
#s-cam .nav { position: fixed; left: 0; right: 0; }
|
||||
/* 拍照页纵向定位按 WebView 口径:Figma 帧 812 高扣掉顶部 86px 微信导航栏 = 726 高视口,百分比均以 726 为基准 */
|
||||
.cam-step {
|
||||
position: absolute; left: 0; right: 0; top: 16.5%;
|
||||
position: absolute; left: 0; right: 0; top: 6.4%;
|
||||
text-align: center;
|
||||
color: #fff; font-size: 21px; font-weight: 500;
|
||||
line-height: 1.5; letter-spacing: 1px;
|
||||
@@ -224,7 +223,7 @@ button { font-family: inherit; border: 0; background: none; cursor: pointer; }
|
||||
z-index: 2;
|
||||
}
|
||||
.cam-guide {
|
||||
position: absolute; left: 0; right: 0; bottom: 20%;
|
||||
position: absolute; left: 0; right: 0; bottom: 22.4%;
|
||||
display: flex; justify-content: center; align-items: center; gap: 18px;
|
||||
z-index: 2;
|
||||
}
|
||||
@@ -248,9 +247,9 @@ button { font-family: inherit; border: 0; background: none; cursor: pointer; }
|
||||
}
|
||||
.cam-fill.on { opacity: 1; transition: none; }
|
||||
|
||||
/* 步骤圆点:当前步高亮、完成步绿色打勾 */
|
||||
/* 步骤圆点:当前步高亮、完成步绿色打勾(Figma 无此元素,放在步骤文案与取景轮廓之间的空隙) */
|
||||
.cam-dots {
|
||||
position: absolute; top: calc(var(--nav-h) + 10px); left: 0; right: 0;
|
||||
position: absolute; top: 16.2%; left: 0; right: 0;
|
||||
display: flex; justify-content: center; gap: 12px;
|
||||
z-index: 2;
|
||||
}
|
||||
@@ -266,7 +265,7 @@ button { font-family: inherit; border: 0; background: none; cursor: pointer; }
|
||||
text-align: center; font-size: 8px; line-height: 10px; color: #fff;
|
||||
}
|
||||
|
||||
/* 取景蒙版:SVG mask 镂空 + 轮廓线框,按 data-step 切换轮廓;框外纯黑不透明(对齐 Figma:只有轮廓内显示画面) */
|
||||
/* 取景蒙版:SVG mask 镂空 + 轮廓线框,按 data-step 切换轮廓;框外 85% 黑 */
|
||||
.cam-mask {
|
||||
position: absolute; inset: 0;
|
||||
width: 100%; height: 100%;
|
||||
@@ -276,7 +275,8 @@ button { font-family: inherit; border: 0; background: none; cursor: pointer; }
|
||||
.cam-mask[data-step="0"] [data-shape="0"],
|
||||
.cam-mask[data-step="1"] [data-shape="1"],
|
||||
.cam-mask[data-step="2"] [data-shape="2"] { display: block; }
|
||||
.cam-dim-rect { fill: #000; } /* 框外全黑(Figma 设计):摄像头画面只在轮廓镂空区可见 */
|
||||
.cam-mask.flip { transform: scaleX(-1); } /* 后置镜头预览不镜像:侧脸轮廓随之翻转 */
|
||||
.cam-dim-rect { fill: #000; fill-opacity: .85; } /* Figma 05-07 框外为 85% 黑(alpha 217):环境隐约可见,人脸只在轮廓内清晰 */
|
||||
.cam-line path { fill: none; stroke: rgba(255,255,255,.85); stroke-width: 2.5; }
|
||||
/* 未检测到人脸:轮廓呼吸动画(~1.8s);姿态 ok:品牌绿定格 */
|
||||
@keyframes maskBreath { 0%,100% { stroke-opacity: .3; } 50% { stroke-opacity: .95; } }
|
||||
@@ -285,7 +285,7 @@ button { font-family: inherit; border: 0; background: none; cursor: pointer; }
|
||||
|
||||
/* 检测引擎加载状态(小字胶囊) */
|
||||
.cam-status {
|
||||
position: absolute; top: calc(var(--nav-h) + 32px); left: 50%;
|
||||
position: absolute; top: 17px; left: 50%; /* 与悬浮返回键同一行,不压步骤文案 */
|
||||
transform: translateX(-50%);
|
||||
font-size: 10px; color: rgba(255,255,255,.85);
|
||||
background: rgba(0,0,0,.35);
|
||||
@@ -565,7 +565,7 @@ button { font-family: inherit; border: 0; background: none; cursor: pointer; }
|
||||
.vm-notes { font-size: 7px; font-weight: 400; color: var(--grey-3); line-height: 1.7; margin: 0 2px 9px; }
|
||||
/* 精华推荐卡 */
|
||||
.essence-name { font-size: 15px; font-weight: 700; color: var(--ink); margin-bottom: 6px; }
|
||||
.essence-triggers { font-size: 7px; color: var(--grey-3); margin-top: 6px; }
|
||||
.essence-triggers { font-size: 8.7px; color: var(--grey-3); margin-top: 6px; } /* 设计规范 Caption 级:验收要求展示命中判据,需可读 */
|
||||
|
||||
/* ═══════════ 第5轮:报告页检测可视化(face_maps 图层 / marks 叠加,新增,不动以上已验收样式) ═══════════ */
|
||||
/* 分类卡检测图复用 .ditem-img(左图右文、92×96 裁切);无检测图时(hidden)卡片退回文字占位 */
|
||||
@@ -673,7 +673,7 @@ button { font-family: inherit; border: 0; background: none; cursor: pointer; }
|
||||
.rs { z-index: 50; }
|
||||
.rs-list { z-index: 50; }
|
||||
/* #4 无进展提示再上移(取景/拍摄区下方,引导 chips 上方) */
|
||||
.cam-nudge { bottom: 24%; }
|
||||
.cam-nudge { bottom: 17.5%; } /* 引导 chips(bottom 22.4%) 与快门之间 */
|
||||
/* #6 报告页头部:按 Figma 08 还原为内联白底顶栏(见报告页主区块),不再悬浮 */
|
||||
|
||||
/* ═══════════ 第9轮:真机反馈 ═══════════ */
|
||||
|
||||
+8
-8
@@ -90,13 +90,14 @@
|
||||
<button class="nav-back nav-dark cam-back-float" data-action="cam-back" aria-label="返回"><i class="chev left"></i></button>
|
||||
<div class="cam-dots" id="cam-dots"><i></i><i></i><i></i></div>
|
||||
<p class="cam-step" id="cam-step">1/3<br>请单人正脸出镜</p>
|
||||
<!-- 取景蒙版:SVG mask 镂空(框外全黑,仅轮廓内显示画面,对齐 Figma) + 轮廓线框;data-step 切换三种轮廓
|
||||
轮廓由 Figma 拍照示意图(design/assets/images/*-拍照示意.png,alpha 镂空区)逐像素描摹生成,等比缩放到 375×812 viewBox
|
||||
0=正脸(含耳部) 1=左脸出镜(头向右转,轮廓鼻朝画面左) 2=右脸出镜(头向左转,鼻朝画面右) -->
|
||||
<svg class="cam-mask" id="cam-mask" data-step="0" viewBox="0 0 375 812" preserveAspectRatio="xMidYMid slice" aria-hidden="true">
|
||||
<!-- 取景蒙版:SVG mask 镂空(框外 85% 黑,对齐 Figma 图层不透明度) + 轮廓线框;data-step 切换三种轮廓
|
||||
轮廓由 Figma 拍照示意图(design/assets/images/*-拍照示意.png,alpha 镂空区)逐像素描摹生成,坐标为 Figma 375×812 整帧;viewBox 只取 y 86–812(帧顶 86px 是微信导航栏,不属于 H5 视口)
|
||||
0=正脸(含耳部) 1=左脸出镜(用户头向自己右侧转,镜像预览中耳在画面左、鼻朝画面右) 2=右脸出镜(头向自己左侧转,耳在画面右、鼻朝画面左)
|
||||
data-step 取 CAM_STEPS[].mask(拍摄顺序 正→右→左,对齐 Figma) -->
|
||||
<svg class="cam-mask" id="cam-mask" data-step="0" viewBox="0 86 375 726" preserveAspectRatio="xMidYMid meet" aria-hidden="true">
|
||||
<defs>
|
||||
<mask id="cam-cut" maskUnits="userSpaceOnUse" x="0" y="0" width="375" height="812">
|
||||
<rect x="0" y="0" width="375" height="812" fill="#fff"/>
|
||||
<mask id="cam-cut" maskUnits="userSpaceOnUse" x="-1000" y="-1000" width="2375" height="2812">
|
||||
<rect x="-1000" y="-1000" width="2375" height="2812" fill="#fff"/>
|
||||
<g fill="#000">
|
||||
<path data-shape="0" d="M54.1 388.5L56.1 387.1L58.2 386.0L60.3 383.5L62.4 382.2L64.5 381.4L66.5 370.5L68.6 357.6L70.7 343.0L72.8 327.1L74.9 310.3L76.9 302.9L79.0 297.1L81.1 292.3L83.2 288.0L85.3 284.2L87.3 280.8L89.4 277.8L91.5 275.0L93.6 272.3L95.7 269.9L97.7 267.6L99.8 265.5L101.9 263.5L104.0 261.6L106.1 259.9L108.1 258.3L110.2 256.8L112.3 255.3L114.4 254.0L116.5 252.7L118.5 251.4L120.6 250.2L122.7 249.1L124.8 248.1L126.8 247.1L128.9 246.2L131.0 245.3L133.1 244.4L135.2 243.7L137.2 242.9L139.3 242.2L141.4 241.6L143.5 241.0L145.6 240.4L147.6 239.9L149.7 239.4L151.8 238.9L153.9 238.5L156.0 238.1L158.0 237.7L160.1 237.4L162.2 237.0L164.3 236.7L166.4 236.4L168.4 236.1L170.5 235.9L172.6 235.8L174.7 235.7L176.8 235.5L178.8 235.4L180.9 235.4L183.0 235.3L185.1 235.3L187.2 235.3L189.2 235.3L191.3 235.3L193.4 235.3L195.5 235.3L197.6 235.3L199.6 235.4L201.7 235.5L203.8 235.7L205.9 235.9L207.9 236.1L210.0 236.4L212.1 236.7L214.2 237.0L216.3 237.4L218.3 237.7L220.4 238.1L222.5 238.5L224.6 238.9L226.7 239.4L228.7 239.9L230.8 240.6L232.9 241.2L235.0 241.9L237.1 242.6L239.1 243.3L241.2 244.0L243.3 244.8L245.4 245.6L247.5 246.5L249.5 247.5L251.6 248.4L253.7 249.5L255.8 250.7L257.9 251.9L259.9 253.2L262.0 254.6L264.1 256.0L266.2 257.4L268.3 259.1L270.3 260.7L272.4 262.5L274.5 264.5L276.6 266.5L278.7 268.7L280.7 271.0L282.8 273.6L284.9 276.4L287.0 279.3L289.0 282.6L291.1 286.2L293.2 290.2L295.3 294.7L297.4 300.1L299.4 306.6L301.5 315.7L303.6 332.1L305.7 347.2L307.8 360.9L309.8 373.0L311.9 381.7L314.0 382.7L316.1 384.5L318.2 387.3L320.2 388.6L321.3 390.2L321.3 408.3L320.2 412.8L318.2 417.1L316.1 425.1L314.0 432.9L311.9 439.5L309.8 445.1L307.8 449.8L305.7 453.7L303.6 456.8L301.5 459.2L299.4 461.1L297.4 462.5L295.3 464.7L293.2 467.6L291.1 471.4L289.0 476.0L287.0 481.4L284.9 486.5L282.8 491.4L280.7 496.0L278.7 500.5L276.6 504.7L274.5 508.7L272.4 512.6L270.3 516.2L268.3 519.7L266.2 523.0L264.1 526.2L262.0 529.3L259.9 532.3L257.9 535.1L255.8 537.9L253.7 540.5L251.6 543.0L249.5 545.3L247.5 547.6L245.4 549.7L243.3 551.8L241.2 553.8L239.1 555.8L237.1 557.6L235.0 559.3L232.9 560.9L230.8 562.4L228.7 563.8L226.7 565.2L224.6 566.5L222.5 567.6L220.4 568.7L218.3 569.8L216.3 570.7L214.2 571.7L212.1 572.6L210.0 573.4L207.9 574.1L205.9 574.7L203.8 575.2L201.7 575.7L199.6 576.2L197.6 576.6L195.5 576.9L193.4 577.1L191.3 577.2L189.2 577.3L187.2 577.4L185.1 577.3L183.0 577.2L180.9 577.0L178.8 576.7L176.8 576.3L174.7 576.0L172.6 575.6L170.5 575.1L168.4 574.6L166.4 573.9L164.3 573.2L162.2 572.3L160.1 571.4L158.0 570.5L156.0 569.4L153.9 568.3L151.8 567.2L149.7 566.0L147.6 564.7L145.6 563.3L143.5 561.8L141.4 560.2L139.3 558.6L137.2 556.9L135.2 555.0L133.1 553.1L131.0 551.1L128.9 549.0L126.8 546.8L124.8 544.4L122.7 542.0L120.6 539.4L118.5 536.8L116.5 534.1L114.4 531.2L112.3 528.2L110.2 525.0L108.1 521.7L106.1 518.2L104.0 514.6L101.9 510.9L99.8 507.0L97.7 502.8L95.7 498.5L93.6 493.9L91.5 489.1L89.4 484.2L87.3 479.0L85.3 473.9L83.2 469.7L81.1 466.3L79.0 463.8L76.9 461.9L74.9 460.3L72.8 458.2L70.7 455.5L68.6 452.1L66.5 447.9L64.5 442.9L62.4 436.8L60.3 429.5L58.2 421.2L56.1 417.0L54.1 412.3Z"/>
|
||||
<path data-shape="1" d="M55.8 396.6L57.9 382.4L60.0 370.8L62.0 353.4L64.1 336.1L66.2 318.3L68.3 309.9L70.4 303.9L72.4 298.9L74.5 294.5L76.6 290.5L78.7 286.9L80.8 283.6L82.8 280.5L84.9 277.6L87.0 274.9L89.1 272.4L91.2 270.1L93.2 267.9L95.3 265.8L97.4 263.8L99.5 262.0L101.5 260.2L103.6 258.5L105.7 256.9L107.8 255.4L109.9 254.0L111.9 252.6L114.0 251.3L116.1 250.0L118.2 248.8L120.3 247.6L122.3 246.6L124.4 245.5L126.5 244.6L128.6 243.7L130.7 242.8L132.7 242.0L134.8 241.3L136.9 240.5L139.0 239.8L141.1 239.2L143.1 238.6L145.2 238.0L147.3 237.5L149.4 237.1L151.5 236.7L153.5 236.3L155.6 236.0L157.7 235.7L159.8 235.3L161.9 235.0L163.9 234.8L166.0 234.5L168.1 234.4L170.2 234.3L172.3 234.2L174.3 234.1L176.4 234.1L178.5 234.0L180.6 233.9L182.6 234.0L184.7 234.1L186.8 234.1L188.9 234.3L191.0 234.4L193.0 234.5L195.1 234.7L197.2 234.9L199.3 235.1L201.4 235.4L203.4 235.6L205.5 235.9L207.6 236.2L209.7 236.6L211.8 236.9L213.8 237.3L215.9 237.8L218.0 238.2L220.1 238.6L222.2 239.1L224.2 239.7L226.3 240.2L228.4 240.8L230.5 241.4L232.6 242.0L234.6 242.6L236.7 243.3L238.8 244.1L240.9 244.8L243.0 245.7L245.0 246.6L247.1 247.4L249.2 248.3L251.3 249.3L253.4 250.2L255.4 251.2L257.5 252.3L259.6 253.4L261.7 254.6L263.7 255.8L265.8 257.1L267.9 258.5L270.0 259.8L272.1 261.3L274.1 262.7L276.2 264.3L278.3 265.9L280.4 267.6L282.5 269.4L284.5 271.4L286.6 273.4L288.7 275.5L290.8 277.7L292.9 279.9L294.9 282.4L297.0 284.9L299.1 287.6L301.2 290.5L303.3 293.7L305.3 297.2L307.4 301.1L309.5 305.6L311.6 311.0L313.7 318.2L315.7 341.8L317.8 349.8L318.9 361.1L318.9 451.5L317.8 456.2L315.7 460.4L313.7 468.7L311.6 476.2L309.5 482.5L307.4 488.1L305.3 493.2L303.3 498.0L301.2 502.5L299.1 506.8L297.0 511.0L294.9 515.0L292.9 518.9L290.8 522.7L288.7 526.5L286.6 530.1L284.5 533.7L282.5 537.2L280.4 540.8L278.3 544.1L276.2 547.5L274.1 550.6L272.1 553.7L270.0 556.5L267.9 559.1L265.8 561.6L263.7 563.8L261.7 565.8L259.6 567.6L257.5 569.2L255.4 570.7L253.4 572.0L251.3 573.2L249.2 574.2L247.1 575.0L245.0 575.8L243.0 576.4L240.9 576.9L238.8 577.2L236.7 577.5L234.6 577.7L232.6 577.7L230.5 577.7L228.4 577.7L226.3 577.5L224.2 577.3L222.2 577.0L220.1 576.6L218.0 576.2L215.9 575.7L213.8 575.2L211.8 574.6L209.7 573.9L207.6 573.2L205.5 572.5L203.4 571.8L201.4 571.0L199.3 570.2L197.2 569.3L195.1 568.3L193.0 567.4L191.0 566.3L188.9 565.3L186.8 564.3L184.7 563.2L182.6 562.2L180.6 561.1L178.5 560.0L176.4 558.8L174.3 557.6L172.3 556.4L170.2 555.1L168.1 553.8L166.0 552.5L163.9 551.1L161.9 549.7L159.8 548.4L157.7 547.0L155.6 545.5L153.5 544.1L151.5 542.6L149.4 541.0L147.3 539.4L145.2 537.9L143.1 536.2L141.1 534.5L139.0 532.8L136.9 531.1L134.8 529.3L132.7 527.5L130.7 525.6L128.6 523.7L126.5 521.7L124.4 519.7L122.3 517.5L120.3 515.3L118.2 512.8L116.1 510.1L114.0 507.2L111.9 503.9L109.9 500.2L107.8 496.1L105.7 491.6L103.6 486.6L101.5 481.1L99.5 476.2L97.4 472.2L95.3 469.2L93.2 467.4L91.2 466.6L89.1 466.1L87.0 465.4L84.9 464.5L82.8 463.4L80.8 462.1L78.7 460.5L76.6 458.5L74.5 456.2L72.4 453.6L70.4 450.6L68.3 447.3L66.2 443.6L64.1 439.4L62.0 434.5L60.0 428.3L57.9 425.3L55.8 422.0Z"/>
|
||||
@@ -104,7 +105,7 @@
|
||||
</g>
|
||||
</mask>
|
||||
</defs>
|
||||
<rect class="cam-dim-rect" x="0" y="0" width="375" height="812" mask="url(#cam-cut)"/>
|
||||
<rect class="cam-dim-rect" x="-1000" y="-1000" width="2375" height="2812" mask="url(#cam-cut)"/>
|
||||
<g class="cam-line">
|
||||
<path data-shape="0" d="M54.1 388.5L56.1 387.1L58.2 386.0L60.3 383.5L62.4 382.2L64.5 381.4L66.5 370.5L68.6 357.6L70.7 343.0L72.8 327.1L74.9 310.3L76.9 302.9L79.0 297.1L81.1 292.3L83.2 288.0L85.3 284.2L87.3 280.8L89.4 277.8L91.5 275.0L93.6 272.3L95.7 269.9L97.7 267.6L99.8 265.5L101.9 263.5L104.0 261.6L106.1 259.9L108.1 258.3L110.2 256.8L112.3 255.3L114.4 254.0L116.5 252.7L118.5 251.4L120.6 250.2L122.7 249.1L124.8 248.1L126.8 247.1L128.9 246.2L131.0 245.3L133.1 244.4L135.2 243.7L137.2 242.9L139.3 242.2L141.4 241.6L143.5 241.0L145.6 240.4L147.6 239.9L149.7 239.4L151.8 238.9L153.9 238.5L156.0 238.1L158.0 237.7L160.1 237.4L162.2 237.0L164.3 236.7L166.4 236.4L168.4 236.1L170.5 235.9L172.6 235.8L174.7 235.7L176.8 235.5L178.8 235.4L180.9 235.4L183.0 235.3L185.1 235.3L187.2 235.3L189.2 235.3L191.3 235.3L193.4 235.3L195.5 235.3L197.6 235.3L199.6 235.4L201.7 235.5L203.8 235.7L205.9 235.9L207.9 236.1L210.0 236.4L212.1 236.7L214.2 237.0L216.3 237.4L218.3 237.7L220.4 238.1L222.5 238.5L224.6 238.9L226.7 239.4L228.7 239.9L230.8 240.6L232.9 241.2L235.0 241.9L237.1 242.6L239.1 243.3L241.2 244.0L243.3 244.8L245.4 245.6L247.5 246.5L249.5 247.5L251.6 248.4L253.7 249.5L255.8 250.7L257.9 251.9L259.9 253.2L262.0 254.6L264.1 256.0L266.2 257.4L268.3 259.1L270.3 260.7L272.4 262.5L274.5 264.5L276.6 266.5L278.7 268.7L280.7 271.0L282.8 273.6L284.9 276.4L287.0 279.3L289.0 282.6L291.1 286.2L293.2 290.2L295.3 294.7L297.4 300.1L299.4 306.6L301.5 315.7L303.6 332.1L305.7 347.2L307.8 360.9L309.8 373.0L311.9 381.7L314.0 382.7L316.1 384.5L318.2 387.3L320.2 388.6L321.3 390.2L321.3 408.3L320.2 412.8L318.2 417.1L316.1 425.1L314.0 432.9L311.9 439.5L309.8 445.1L307.8 449.8L305.7 453.7L303.6 456.8L301.5 459.2L299.4 461.1L297.4 462.5L295.3 464.7L293.2 467.6L291.1 471.4L289.0 476.0L287.0 481.4L284.9 486.5L282.8 491.4L280.7 496.0L278.7 500.5L276.6 504.7L274.5 508.7L272.4 512.6L270.3 516.2L268.3 519.7L266.2 523.0L264.1 526.2L262.0 529.3L259.9 532.3L257.9 535.1L255.8 537.9L253.7 540.5L251.6 543.0L249.5 545.3L247.5 547.6L245.4 549.7L243.3 551.8L241.2 553.8L239.1 555.8L237.1 557.6L235.0 559.3L232.9 560.9L230.8 562.4L228.7 563.8L226.7 565.2L224.6 566.5L222.5 567.6L220.4 568.7L218.3 569.8L216.3 570.7L214.2 571.7L212.1 572.6L210.0 573.4L207.9 574.1L205.9 574.7L203.8 575.2L201.7 575.7L199.6 576.2L197.6 576.6L195.5 576.9L193.4 577.1L191.3 577.2L189.2 577.3L187.2 577.4L185.1 577.3L183.0 577.2L180.9 577.0L178.8 576.7L176.8 576.3L174.7 576.0L172.6 575.6L170.5 575.1L168.4 574.6L166.4 573.9L164.3 573.2L162.2 572.3L160.1 571.4L158.0 570.5L156.0 569.4L153.9 568.3L151.8 567.2L149.7 566.0L147.6 564.7L145.6 563.3L143.5 561.8L141.4 560.2L139.3 558.6L137.2 556.9L135.2 555.0L133.1 553.1L131.0 551.1L128.9 549.0L126.8 546.8L124.8 544.4L122.7 542.0L120.6 539.4L118.5 536.8L116.5 534.1L114.4 531.2L112.3 528.2L110.2 525.0L108.1 521.7L106.1 518.2L104.0 514.6L101.9 510.9L99.8 507.0L97.7 502.8L95.7 498.5L93.6 493.9L91.5 489.1L89.4 484.2L87.3 479.0L85.3 473.9L83.2 469.7L81.1 466.3L79.0 463.8L76.9 461.9L74.9 460.3L72.8 458.2L70.7 455.5L68.6 452.1L66.5 447.9L64.5 442.9L62.4 436.8L60.3 429.5L58.2 421.2L56.1 417.0L54.1 412.3Z"/>
|
||||
<path data-shape="1" d="M55.8 396.6L57.9 382.4L60.0 370.8L62.0 353.4L64.1 336.1L66.2 318.3L68.3 309.9L70.4 303.9L72.4 298.9L74.5 294.5L76.6 290.5L78.7 286.9L80.8 283.6L82.8 280.5L84.9 277.6L87.0 274.9L89.1 272.4L91.2 270.1L93.2 267.9L95.3 265.8L97.4 263.8L99.5 262.0L101.5 260.2L103.6 258.5L105.7 256.9L107.8 255.4L109.9 254.0L111.9 252.6L114.0 251.3L116.1 250.0L118.2 248.8L120.3 247.6L122.3 246.6L124.4 245.5L126.5 244.6L128.6 243.7L130.7 242.8L132.7 242.0L134.8 241.3L136.9 240.5L139.0 239.8L141.1 239.2L143.1 238.6L145.2 238.0L147.3 237.5L149.4 237.1L151.5 236.7L153.5 236.3L155.6 236.0L157.7 235.7L159.8 235.3L161.9 235.0L163.9 234.8L166.0 234.5L168.1 234.4L170.2 234.3L172.3 234.2L174.3 234.1L176.4 234.1L178.5 234.0L180.6 233.9L182.6 234.0L184.7 234.1L186.8 234.1L188.9 234.3L191.0 234.4L193.0 234.5L195.1 234.7L197.2 234.9L199.3 235.1L201.4 235.4L203.4 235.6L205.5 235.9L207.6 236.2L209.7 236.6L211.8 236.9L213.8 237.3L215.9 237.8L218.0 238.2L220.1 238.6L222.2 239.1L224.2 239.7L226.3 240.2L228.4 240.8L230.5 241.4L232.6 242.0L234.6 242.6L236.7 243.3L238.8 244.1L240.9 244.8L243.0 245.7L245.0 246.6L247.1 247.4L249.2 248.3L251.3 249.3L253.4 250.2L255.4 251.2L257.5 252.3L259.6 253.4L261.7 254.6L263.7 255.8L265.8 257.1L267.9 258.5L270.0 259.8L272.1 261.3L274.1 262.7L276.2 264.3L278.3 265.9L280.4 267.6L282.5 269.4L284.5 271.4L286.6 273.4L288.7 275.5L290.8 277.7L292.9 279.9L294.9 282.4L297.0 284.9L299.1 287.6L301.2 290.5L303.3 293.7L305.3 297.2L307.4 301.1L309.5 305.6L311.6 311.0L313.7 318.2L315.7 341.8L317.8 349.8L318.9 361.1L318.9 451.5L317.8 456.2L315.7 460.4L313.7 468.7L311.6 476.2L309.5 482.5L307.4 488.1L305.3 493.2L303.3 498.0L301.2 502.5L299.1 506.8L297.0 511.0L294.9 515.0L292.9 518.9L290.8 522.7L288.7 526.5L286.6 530.1L284.5 533.7L282.5 537.2L280.4 540.8L278.3 544.1L276.2 547.5L274.1 550.6L272.1 553.7L270.0 556.5L267.9 559.1L265.8 561.6L263.7 563.8L261.7 565.8L259.6 567.6L257.5 569.2L255.4 570.7L253.4 572.0L251.3 573.2L249.2 574.2L247.1 575.0L245.0 575.8L243.0 576.4L240.9 576.9L238.8 577.2L236.7 577.5L234.6 577.7L232.6 577.7L230.5 577.7L228.4 577.7L226.3 577.5L224.2 577.3L222.2 577.0L220.1 576.6L218.0 576.2L215.9 575.7L213.8 575.2L211.8 574.6L209.7 573.9L207.6 573.2L205.5 572.5L203.4 571.8L201.4 571.0L199.3 570.2L197.2 569.3L195.1 568.3L193.0 567.4L191.0 566.3L188.9 565.3L186.8 564.3L184.7 563.2L182.6 562.2L180.6 561.1L178.5 560.0L176.4 558.8L174.3 557.6L172.3 556.4L170.2 555.1L168.1 553.8L166.0 552.5L163.9 551.1L161.9 549.7L159.8 548.4L157.7 547.0L155.6 545.5L153.5 544.1L151.5 542.6L149.4 541.0L147.3 539.4L145.2 537.9L143.1 536.2L141.1 534.5L139.0 532.8L136.9 531.1L134.8 529.3L132.7 527.5L130.7 525.6L128.6 523.7L126.5 521.7L124.4 519.7L122.3 517.5L120.3 515.3L118.2 512.8L116.1 510.1L114.0 507.2L111.9 503.9L109.9 500.2L107.8 496.1L105.7 491.6L103.6 486.6L101.5 481.1L99.5 476.2L97.4 472.2L95.3 469.2L93.2 467.4L91.2 466.6L89.1 466.1L87.0 465.4L84.9 464.5L82.8 463.4L80.8 462.1L78.7 460.5L76.6 458.5L74.5 456.2L72.4 453.6L70.4 450.6L68.3 447.3L66.2 443.6L64.1 439.4L62.0 434.5L60.0 428.3L57.9 425.3L55.8 422.0Z"/>
|
||||
@@ -238,7 +239,6 @@
|
||||
<div class="card card-essence">
|
||||
<h2 class="card-heading">精华推荐</h2>
|
||||
<p class="essence-name" id="essence-name">--</p>
|
||||
<p class="care-item" id="essence-text">--</p>
|
||||
<p class="essence-triggers" id="essence-triggers"></p>
|
||||
</div>
|
||||
|
||||
|
||||
+148
-62
@@ -10,7 +10,8 @@
|
||||
toastEl.textContent = msg;
|
||||
toastEl.classList.add('show');
|
||||
clearTimeout(toastTimer);
|
||||
toastTimer = setTimeout(function () { toastEl.classList.remove('show'); }, 1800);
|
||||
// 长文案(如检测失败原因)按字数延长停留,短提示仍 1.8s
|
||||
toastTimer = setTimeout(function () { toastEl.classList.remove('show'); }, Math.max(1800, String(msg).length * 130));
|
||||
}
|
||||
|
||||
/* ─── 屏幕管理 ─── */
|
||||
@@ -64,6 +65,7 @@
|
||||
$('#p-analyzing').addEventListener('click', function (e) {
|
||||
if (e.target !== this) return; // 只响应遮罩空白处,不误伤卡片
|
||||
camSeq++;
|
||||
voice.stop(true);
|
||||
closeOverlay('p-analyzing');
|
||||
stopCamera();
|
||||
shutterLock = false; // 流程终止解锁
|
||||
@@ -164,20 +166,30 @@
|
||||
});
|
||||
document.addEventListener('click', rsClose); // 点空白收起
|
||||
|
||||
// 年龄只收数字:inputmode=numeric 在部分安卓输入法仍可切到全键盘
|
||||
$('#f-age').addEventListener('input', function () {
|
||||
var v = this.value.replace(/\D/g, '').slice(0, 2);
|
||||
if (v !== this.value) this.value = v;
|
||||
});
|
||||
|
||||
/* ─── 实时相机(getUserMedia)+ 系统相机降级 ─── */
|
||||
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 同序;送检前经 photosByView 重排为 [正脸, 左脸, 右脸]
|
||||
|
||||
// 约束逐级放宽:facingMode 裸值在部分 Android 抛 OverconstrainedError,先 ideal 再退 video:true,最后才降级
|
||||
var camFacing = 'user'; // 当前镜头:'user' 前置 | 'environment' 后置(翻转按钮切换)
|
||||
var camMirrored = true; // 前置镜像开关:仅正脸步生效(自拍习惯);侧脸步始终不镜像(左右不再迷惑,与取景轮廓方向一致)
|
||||
var camMirrored = true; // 预览镜像:前置 true(照镜子习惯,Figma 侧脸轮廓即按镜像画面绘制),后置 false
|
||||
var sideYawFlip = false; // 侧脸步语义 yaw 反向标志:个别机型前置帧本身已镜像时由自校准翻转
|
||||
var mirrorCalibrated = false; // 侧脸自校准:本轮是否已翻转过一次(防振荡,一次校准管整个会话)
|
||||
var oppFrames = 0; // 侧脸步语义 yaw 连续反向帧计数
|
||||
// 预览/抓帧是否镜像:正脸步跟随 camMirrored,侧脸步恒不镜像
|
||||
function effMirror() { return camMirrored && CAM_STEPS[camStep].target === 'front'; }
|
||||
// 预览是否镜像(仅预览;抓帧恒为原始视角)。后置不镜像时侧脸轮廓需水平翻转,才与画面中的脸朝向一致
|
||||
function effMirror() { return camMirrored; }
|
||||
function applyMirror() {
|
||||
camVideo.classList.toggle('mirror', effMirror());
|
||||
$('#cam-mask').classList.toggle('flip', !effMirror() && CAM_STEPS[camStep].target !== 'front');
|
||||
}
|
||||
function getCamStream() {
|
||||
return navigator.mediaDevices.getUserMedia({
|
||||
video: { facingMode: { ideal: camFacing }, width: { ideal: 1280 }, height: { ideal: 1280 } },
|
||||
@@ -210,7 +222,7 @@
|
||||
if (token !== camSeqCam) { stream.getTracks().forEach(function (t) { t.stop(); }); return; } // 期间已退出拍照页:立即释放
|
||||
camStream = stream;
|
||||
camVideo.srcObject = stream;
|
||||
camVideo.classList.toggle('mirror', effMirror());
|
||||
applyMirror();
|
||||
// iOS 来电/切后台等系统打断会结束 track:挂 ended 监听走自动重连
|
||||
stream.getTracks().forEach(function (t) {
|
||||
t.onended = function () { onCamTrackEnded(token); };
|
||||
@@ -250,8 +262,9 @@
|
||||
resetDetectState(); // 降级无检测、无补光:蒙版回到静态线框
|
||||
toast('已切换为系统相机拍摄');
|
||||
}
|
||||
// 从视频帧截取照片(完整帧,不镜像侧脸步;压到最长边 1280 的 JPEG)
|
||||
// 从视频帧截取照片(完整帧、摄像头原始视角,压到最长边 1280 的 JPEG)
|
||||
// 抓完整帧而非取景框可见区:新 UI 轮廓外全黑,用户只关注轮廓内人脸;按可见区裁切在侧脸/偏站位时会切掉人脸(Face++ INVALID_IMAGE_FACE)
|
||||
// 正脸步预览虽镜像,照片不镜像:Face++ 的左右分区(左嘴角纹/左右脸颊等)按真实左右出文案,且与两张侧脸及真值样本同口径
|
||||
function captureFromVideo() {
|
||||
var vw = camVideo.videoWidth, vh = camVideo.videoHeight;
|
||||
if (!vw || !vh) return null;
|
||||
@@ -260,7 +273,6 @@
|
||||
var canvas = document.createElement('canvas');
|
||||
canvas.width = cw; canvas.height = ch;
|
||||
var ctx = canvas.getContext('2d');
|
||||
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);
|
||||
}
|
||||
@@ -302,7 +314,7 @@
|
||||
/* ─── 语音播报(WebAudio 单通道;assets/voice/<id>.mp3,缺失静默跳过;同条 4s 去重)
|
||||
文案/文件名契约:docs/语音播报内容设计文档_V1.0.md ─── */
|
||||
var VOICE = {
|
||||
step: ['step1-front', 'step2-leftface', 'step3-rightface'],
|
||||
step: ['step1-front', 'step2-rightface', 'step3-leftface'],
|
||||
hint: {
|
||||
'未检测到人脸': 'hint-noface', '请靠近一点': 'hint-near', '请远一点': 'hint-far',
|
||||
'请正对屏幕': 'hint-front', '请向左转头': 'hint-turn-left', '请向右转头': 'hint-turn-right',
|
||||
@@ -310,51 +322,110 @@
|
||||
},
|
||||
countdown: 'countdown-321', shot: 'shot-done', analyzing: 'analyzing', fail: 'analyze-fail'
|
||||
};
|
||||
// 两类播报:流程音(步骤引导/拍摄完成/倒计时/分析中/失败)排队顺序播,互不截断;
|
||||
// 姿态提示可互相打断,但不截断流程音——流程音播放期间只挂起最新一条,收尾后补播
|
||||
var voice = (function () {
|
||||
var ctx = null, lastAt = {}, cur = null, curEnd = 0, files = null; // files:null=清单未加载;{}=无可用语音
|
||||
var ctx = null, lastAt = {}, files = null; // files:null=清单未加载;{}=无可用语音
|
||||
var bufs = {}; // id → Promise<AudioBuffer|null>(解锁后预加载,弱网下首播不等下载)
|
||||
var cur = null; // { id, kind:'flow'|'hint', token, src, end }
|
||||
var queue = []; // 排队中的流程音
|
||||
var pendingHint = null; // 流程音期间到来的姿态提示(只留最新)
|
||||
var waiters = []; // afterCurrent 回调:全部播报收尾后执行
|
||||
// 清单制:只播 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; }); })
|
||||
.then(function (m) { files = {}; (m.files || []).forEach(function (f) { files[f] = 1; }); preload(); })
|
||||
.catch(function () { files = {}; });
|
||||
function decode(ab) {
|
||||
return new Promise(function (resolve) {
|
||||
try {
|
||||
var p = ctx.decodeAudioData(ab, resolve, function () { resolve(null); });
|
||||
if (p && p.catch) p.catch(function () { resolve(null); });
|
||||
} catch (e) { resolve(null); }
|
||||
});
|
||||
}
|
||||
function load(id) {
|
||||
if (!bufs[id]) {
|
||||
bufs[id] = fetch('assets/voice/' + id + '.mp3')
|
||||
.then(function (r) { return r.ok ? r.arrayBuffer() : null; })
|
||||
.then(function (ab) { return ab ? decode(ab) : null; })
|
||||
.catch(function () { return null; })
|
||||
.then(function (buf) { if (!buf) delete bufs[id]; return buf; }); // 失败不缓存,下次再试
|
||||
}
|
||||
return bufs[id];
|
||||
}
|
||||
function preload() { if (ctx && files) Object.keys(files).forEach(load); }
|
||||
function unlock() { // iOS/微信自动播放限制:必须在用户手势里初始化并 resume
|
||||
if (!ctx) { try { ctx = new (window.AudioContext || window.webkitAudioContext)(); } catch (e) { ctx = null; } }
|
||||
if (!ctx) {
|
||||
try { ctx = new (window.AudioContext || window.webkitAudioContext)(); } catch (e) { ctx = null; }
|
||||
preload();
|
||||
}
|
||||
if (ctx && ctx.state === 'suspended') ctx.resume();
|
||||
}
|
||||
function play(id) {
|
||||
function stopCur() {
|
||||
if (!cur) return;
|
||||
var c = cur;
|
||||
cur = null;
|
||||
if (c.src) { c.src.onended = null; try { c.src.stop(); } catch (e) {} }
|
||||
}
|
||||
function start(id, kind) {
|
||||
stopCur();
|
||||
lastAt[id] = Date.now();
|
||||
var token = {};
|
||||
cur = { id: id, kind: kind, token: token, src: null, end: 0 };
|
||||
load(id).then(function (buf) {
|
||||
if (!cur || cur.token !== token) return; // 加载期间已被打断
|
||||
if (!buf || !ctx) { cur = null; drain(); return; }
|
||||
var src = ctx.createBufferSource();
|
||||
src.buffer = buf;
|
||||
src.connect(ctx.destination);
|
||||
cur.src = src;
|
||||
cur.end = Date.now() + buf.duration * 1000;
|
||||
src.onended = function () { if (cur && cur.token === token) { cur = null; drain(); } };
|
||||
src.start(0);
|
||||
});
|
||||
}
|
||||
function drain() {
|
||||
if (cur) return;
|
||||
if (queue.length) { start(queue.shift(), 'flow'); return; }
|
||||
if (pendingHint) { var h = pendingHint; pendingHint = null; start(h, 'hint'); return; }
|
||||
var w = waiters;
|
||||
waiters = [];
|
||||
w.forEach(function (fn) { fn(); });
|
||||
}
|
||||
function busyWithFlow() { return (cur && cur.kind === 'flow') || queue.length > 0; }
|
||||
// opts.hint:姿态提示(随检测帧反复触发,同条 4s 去重);opts.cut:打断在播与排队(失败提示不必等「分析中」播完)
|
||||
// 流程音是离散事件,每次都播(连拍的「拍摄完成」、取消后重来的倒计时)
|
||||
function play(id, opts) {
|
||||
opts = opts || {};
|
||||
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) {} }
|
||||
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 () { /* 网络异常忽略 */ });
|
||||
if (opts.hint) {
|
||||
if (lastAt[id] && Date.now() - lastAt[id] < 4000) return;
|
||||
if (busyWithFlow()) { pendingHint = id; return; }
|
||||
start(id, 'hint');
|
||||
return;
|
||||
}
|
||||
if (opts.cut) { queue = []; pendingHint = null; start(id, 'flow'); return; }
|
||||
if (busyWithFlow()) { if (queue.indexOf(id) < 0) queue.push(id); return; }
|
||||
start(id, 'flow'); // 在播的姿态提示让位给流程音
|
||||
}
|
||||
// 等当前播报自然收尾后执行 cb(倒计时接续「保持不动」,不拦腰切断);无播报在执行则立即执行
|
||||
// 返回取消函数;注意:cur 被新播报 stop 也会触发 onended,调用方需配合 cancelCountdown 等路径取消
|
||||
function dropHint() { pendingHint = null; } // 提示已清空/换步:挂起的旧提示作废
|
||||
// 流程切走:作废排队/挂起/等待;hard 连在播一并掐断(退出拍照、页面隐藏),否则让在播自然收尾(出报告)
|
||||
function stop(hard) {
|
||||
queue = [];
|
||||
pendingHint = null;
|
||||
waiters = [];
|
||||
if (hard) stopCur();
|
||||
}
|
||||
// 等全部播报(含排队与挂起提示)自然收尾后执行 cb(倒计时接续「保持不动」,不拦腰切断);空闲则立即执行
|
||||
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; };
|
||||
var tail = cur && cur.src && Date.now() >= cur.end - 60; // 只剩尾音视为已收尾
|
||||
if ((!cur || tail) && !queue.length && !pendingHint) { cb(); return null; }
|
||||
var fn = function () { cb(); };
|
||||
waiters.push(fn);
|
||||
return function () { waiters = waiters.filter(function (w) { return w !== fn; }); };
|
||||
}
|
||||
return { unlock: unlock, play: play, afterCurrent: afterCurrent };
|
||||
return { unlock: unlock, play: play, dropHint: dropHint, afterCurrent: afterCurrent, stop: stop };
|
||||
})();
|
||||
document.addEventListener('click', voice.unlock); // 任意点击即完成解锁
|
||||
|
||||
@@ -397,10 +468,10 @@
|
||||
}
|
||||
function setHint(text, ok, silent) {
|
||||
var h = $('#cam-hint');
|
||||
if (!text) { h.classList.remove('show', 'ok'); lastHint = ''; return; }
|
||||
if (!text) { h.classList.remove('show', 'ok'); lastHint = ''; voice.dropHint(); return; }
|
||||
if (text !== lastHint) {
|
||||
h.textContent = text; // 同文案不重复触发布局
|
||||
if (!silent) voice.play(VOICE.hint[text]); // 语音同步(模块内 4s 去重)
|
||||
if (!silent) voice.play(VOICE.hint[text], { hint: true }); // 语音同步(模块内 4s 去重)
|
||||
}
|
||||
h.classList.add('show');
|
||||
h.classList.toggle('ok', !!ok);
|
||||
@@ -516,8 +587,8 @@
|
||||
if (ratio < FACE_NEAR) hint = '请靠近一点';
|
||||
else if (ratio > FACE_FAR) hint = '请远一点';
|
||||
else {
|
||||
// 距离不合格时角度不判 ok。镜像参数:正脸步跟随镜头(前置 true/后置 false);
|
||||
// 侧脸步预览不镜像,特征点即原始视角,标准机型恒为 false,个别前置帧已镜像的机型由自校准翻转 sideYawFlip
|
||||
// 距离不合格时角度不判 ok。特征点取自原始帧(预览的 CSS 镜像不影响),侧脸步语义方向按原始视角判定:
|
||||
// 标准机型恒为 false,个别前置帧本身已镜像的机型由自校准翻转 sideYawFlip;正脸步只看 |yaw|,与符号无关
|
||||
var tgt = CAM_STEPS[camStep].target;
|
||||
var pose = HeadPose.check(d.landmarks.positions, tgt, tgt === 'front' ? camMirrored : sideYawFlip);
|
||||
// 侧脸自校准:个别机型前置帧本身已镜像(语义 yaw 与实际转向持续相反)时自救——
|
||||
@@ -550,19 +621,24 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── 拍照流程(三步:正脸 → 左脸出镜 → 右脸出镜) ─── */
|
||||
// target 即 HeadPose.check 的 target:2/3 左脸出镜=用户头向右转(target 'left',产物作 left_image);
|
||||
// 3/3 右脸出镜=头向左转(target 'right',产物作 right_image)。
|
||||
/* ─── 拍照流程(三步,顺序对齐 Figma:正脸 → 右脸出镜 → 左脸出镜) ─── */
|
||||
// 左右一律按用户本人的左右:右脸出镜=用户头向自己的左侧转(target 'right',产物作 right_image);
|
||||
// 左脸出镜=头向自己的右侧转(target 'left',产物作 left_image)。
|
||||
// mask 为 index.html 取景轮廓编号(1=左脸出镜 耳在画面左,2=右脸出镜 耳在画面右,均按镜像预览绘制)
|
||||
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: 'left' },
|
||||
{ bg: 'assets/img/cam-right.jpg', step: '3/3<br>请单人右脸出镜', guides: ['右脸居中', '取下眼镜', '光线充足'], target: 'right' }
|
||||
{ step: '1/3<br>请单人正脸出镜', guides: ['正脸居中', '取下眼镜', '光线充足'], target: 'front', mask: 0 },
|
||||
{ step: '2/3<br>请单人右脸出镜', guides: ['右脸居中', '取下眼镜', '光线充足'], target: 'right', mask: 2 },
|
||||
{ step: '3/3<br>请单人左脸出镜', guides: ['左脸居中', '取下眼镜', '光线充足'], target: 'left', mask: 1 }
|
||||
];
|
||||
function photosByView(ph) { // 拍摄顺序 → 送检/报告/历史统一口径 [正脸, 左脸, 右脸]
|
||||
var at = {};
|
||||
CAM_STEPS.forEach(function (s, i) { at[s.target] = ph[i]; });
|
||||
return [at.front, at.left, at.right];
|
||||
}
|
||||
var camStep = 0;
|
||||
|
||||
function renderCamStep() {
|
||||
var cfg = CAM_STEPS[camStep];
|
||||
$('#cam-bg').style.backgroundImage = 'url(' + cfg.bg + ')';
|
||||
$('#cam-step').innerHTML = cfg.step;
|
||||
$('#cam-guide').innerHTML = cfg.guides
|
||||
.map(function (g) { return '<span>' + g + '</span>'; })
|
||||
@@ -573,8 +649,8 @@
|
||||
dots[i].classList.toggle('done', i < camStep);
|
||||
dots[i].classList.toggle('on', i === camStep);
|
||||
}
|
||||
$('#cam-mask').setAttribute('data-step', camStep); // 蒙版按步骤切换
|
||||
camVideo.classList.toggle('mirror', effMirror()); // 侧脸步预览切回不镜像(正脸步恢复前置镜像)
|
||||
$('#cam-mask').setAttribute('data-step', cfg.mask); // 蒙版按步骤切换轮廓
|
||||
applyMirror();
|
||||
resetDetectState();
|
||||
voice.play(VOICE.step[camStep]); // 步骤引导语音
|
||||
}
|
||||
@@ -587,7 +663,7 @@
|
||||
shutterLock = true; // 推进窗口上锁:降级模式 change 里已解锁,这里重新锁住直到下一步/送检
|
||||
photos[camStep] = dataUrl;
|
||||
$('#cam-dots').children[camStep].classList.add('done');
|
||||
voice.play(VOICE.shot); // 「拍摄完成」
|
||||
voice.play(VOICE.shot, { cut: true }); // 「拍摄完成」:已拍下,本步未播完的引导作废(自动拍时倒计时本就等播报收尾)
|
||||
var seq = camSeq;
|
||||
setTimeout(function () {
|
||||
if (seq !== camSeq) return; // 期间已 cam-back/重新开始:放弃本次推进
|
||||
@@ -626,9 +702,10 @@
|
||||
}, 300);
|
||||
}
|
||||
|
||||
// 三张拍完:三视图送检(正脸=photos[0],左=photos[1],右=photos[2])
|
||||
// 三张拍完:按视角重排后送检(正脸=photos[0],左脸=photos[1],右脸=photos[2])
|
||||
function submitPhotos(seq) {
|
||||
camStep = 0;
|
||||
photos = photosByView(photos);
|
||||
openOverlay('p-analyzing');
|
||||
voice.play(VOICE.analyzing); // 「正在生成您的肌肤报告」
|
||||
var t0 = Date.now();
|
||||
@@ -639,9 +716,10 @@
|
||||
if (seq !== camSeq) return;
|
||||
closeOverlay('p-analyzing');
|
||||
stopCamera();
|
||||
voice.stop(); // 结果已出:排队中的「分析中」等不再补播
|
||||
shutterLock = false; // 流程结束解锁
|
||||
if (errMsg) {
|
||||
voice.play(VOICE.fail);
|
||||
voice.play(VOICE.fail, { cut: true });
|
||||
toast(errMsg);
|
||||
showScreen('s-profile'); // 回档案页,允许重新提交再拍
|
||||
return;
|
||||
@@ -969,7 +1047,9 @@
|
||||
// 降级标注:只展示用户可感知/可执行的(下颌线、侧脸、补拍类);
|
||||
// 引擎内部技术项(上限锁未执行/字段未返回等)不面向用户展示
|
||||
var notesEl = $('#vm-notes');
|
||||
var userNotes = (vm.notes || []).filter(function (t) { return /下颌线|侧脸|补拍|重拍/.test(t); });
|
||||
var userNotes = (vm.notes || [])
|
||||
.filter(function (t) { return /下颌线|侧脸|补拍|重拍/.test(t); })
|
||||
.map(function (t) { return t.replace(/\(quality=[^)]*\)/g, ''); }); // 接口质量码仅留在引擎留痕里
|
||||
if (userNotes.length) {
|
||||
notesEl.innerHTML = userNotes.map(function (t) { return '*' + esc(t); }).join('<br>');
|
||||
notesEl.style.display = '';
|
||||
@@ -987,12 +1067,16 @@
|
||||
}
|
||||
|
||||
// 精华推荐卡
|
||||
// 精华推荐卡只放产品名 + 命中判据:推荐正文已是护理建议第 4 段(V2.3 §8.5),卡内不再重复同一句
|
||||
$('#essence-name').textContent = vm.essence.name;
|
||||
$('#essence-text').textContent = vm.essence.text;
|
||||
// 命中判据:引擎内部名 → 用户可读措辞(引擎值被真值回归逐字锁定,仅展示层映射)
|
||||
var TRIGGER_LABEL = { '均分档': '综合均分', '最差项约束': '最低分维度', '年龄组档': '年龄段' };
|
||||
var TRIGGER_LABEL = {
|
||||
'均分档': vm.avgS != null ? '六项平均分 ' + vm.avgS : '六项平均分',
|
||||
'最差项约束': vm.worst ? vm.worst.name + '得分偏低' : '单项得分偏低',
|
||||
'年龄组档': vm.group ? '年龄段 ' + vm.group.replace('组', '') : '年龄段'
|
||||
};
|
||||
$('#essence-triggers').textContent = vm.essence.triggers.length
|
||||
? '推荐依据:' + vm.essence.triggers.map(function (t) { return TRIGGER_LABEL[t] || t; }).join('、')
|
||||
? '推荐依据:' + vm.essence.triggers.map(function (t) { return TRIGGER_LABEL[t] || t; }).join('、')
|
||||
: '';
|
||||
}
|
||||
|
||||
@@ -1077,6 +1161,7 @@
|
||||
},
|
||||
'cam-back': function () {
|
||||
camSeq++; // 使未决的快门/分析回调失效,避免回退被旧定时器顶回
|
||||
voice.stop(true); // 回退/退出:上一步的播报不再接着念
|
||||
shutterLock = false; // 中途退出也解锁
|
||||
if (camStep > 0) { camStep--; renderCamStep(); } // renderCamStep 内含检测状态机重置
|
||||
else { stopCamera(); showScreen('s-profile'); }
|
||||
@@ -1193,6 +1278,7 @@
|
||||
function handleCamHidden() {
|
||||
if (currentScreen !== 's-cam') return;
|
||||
camSeq++; // 使未决的快门/推进/分析回调失效(白屏定时器醒来即自洁退出)
|
||||
voice.stop(true);
|
||||
shutterLock = false;
|
||||
capturing = false;
|
||||
$('#cam-fill').classList.remove('on'); // 白屏若亮着立即熄灭
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* 无头 Chromium 冒烟:mock 三连拍走到报告页,并核对语音清单是否真的被拉取、解码、起播。
|
||||
*
|
||||
* 依赖:全局 playwright + `npx playwright install chromium`
|
||||
* 服务:node h5/server.js(默认 http://127.0.0.1:8080)
|
||||
*
|
||||
* node h5/test/e2e-browser.mjs
|
||||
*/
|
||||
import { execSync } from 'node:child_process';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
|
||||
const pwRoot = execSync('npm root -g', { encoding: 'utf8' }).trim();
|
||||
const { chromium } = await import(pathToFileURL(pwRoot + '/playwright/index.mjs').href);
|
||||
|
||||
const BASE = process.env.BASE_URL || 'http://127.0.0.1:8080';
|
||||
const EXPECTED = [
|
||||
'step1-front', 'step2-rightface', 'step3-leftface',
|
||||
'hint-noface', 'hint-near', 'hint-far', 'hint-front',
|
||||
'hint-turn-left', 'hint-turn-right', 'hint-hold',
|
||||
'countdown-321', 'shot-done', 'analyzing', 'analyze-fail',
|
||||
];
|
||||
|
||||
const fails = [];
|
||||
function check(name, ok, detail) {
|
||||
const mark = ok ? 'PASS' : 'FAIL';
|
||||
console.log(`${mark} ${name}${detail ? ' ' + detail : ''}`);
|
||||
if (!ok) fails.push(name + (detail ? ': ' + detail : ''));
|
||||
}
|
||||
|
||||
const browser = await chromium.launch({
|
||||
headless: true,
|
||||
args: [
|
||||
'--use-fake-ui-for-media-stream',
|
||||
'--use-fake-device-for-media-stream',
|
||||
'--autoplay-policy=no-user-gesture-required',
|
||||
],
|
||||
});
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: 375, height: 812 },
|
||||
permissions: ['camera'],
|
||||
});
|
||||
// 语音解锁后整批预加载,请求顺序不代表播放顺序:按 ArrayBuffer → AudioBuffer 反查条目名,记录真实起播与截断
|
||||
await context.addInitScript(() => {
|
||||
window.__voice = [];
|
||||
const abId = new WeakMap(), bufId = new WeakMap();
|
||||
const origFetch = window.fetch;
|
||||
window.fetch = function (url, opts) {
|
||||
const m = String(url).match(/voice\/([a-z0-9-]+)\.mp3/);
|
||||
return origFetch.call(this, url, opts).then((res) => {
|
||||
if (!m) return res;
|
||||
const origAb = res.arrayBuffer.bind(res);
|
||||
res.arrayBuffer = () => origAb().then((ab) => { abId.set(ab, m[1]); return ab; });
|
||||
return res;
|
||||
});
|
||||
};
|
||||
const Orig = window.AudioContext || window.webkitAudioContext;
|
||||
function WrappedAudioContext(...args) {
|
||||
const ctx = new Orig(...args);
|
||||
const origDecode = ctx.decodeAudioData.bind(ctx);
|
||||
ctx.decodeAudioData = function (ab, ok, err) {
|
||||
const id = abId.get(ab);
|
||||
return origDecode(ab, (buf) => { bufId.set(buf, id); if (ok) ok(buf); }, err);
|
||||
};
|
||||
const origCreate = ctx.createBufferSource.bind(ctx);
|
||||
ctx.createBufferSource = function () {
|
||||
const src = origCreate();
|
||||
const origStart = src.start.bind(src), origStop = src.stop.bind(src);
|
||||
let rec = null;
|
||||
src.start = function (...a) {
|
||||
rec = { id: bufId.get(src.buffer) || '?', t: Date.now(), dur: Math.round(src.buffer.duration * 1000), cutAt: null };
|
||||
window.__voice.push(rec);
|
||||
return origStart(...a);
|
||||
};
|
||||
src.stop = function (...a) {
|
||||
if (rec && rec.cutAt == null) rec.cutAt = Date.now() - rec.t;
|
||||
return origStop(...a);
|
||||
};
|
||||
return src;
|
||||
};
|
||||
return ctx;
|
||||
}
|
||||
WrappedAudioContext.prototype = Orig.prototype;
|
||||
window.AudioContext = WrappedAudioContext;
|
||||
window.webkitAudioContext = WrappedAudioContext;
|
||||
});
|
||||
|
||||
const page = await context.newPage();
|
||||
const pageErrors = [];
|
||||
page.on('pageerror', (err) => pageErrors.push(String(err)));
|
||||
|
||||
try {
|
||||
// 1) 14 条语音文件与清单都在,且 HTTP 200
|
||||
const manifest = await (await page.request.get(BASE + '/assets/voice/manifest.json')).json();
|
||||
const listed = manifest.files || [];
|
||||
check('清单含设计文档 14 条', EXPECTED.every((id) => listed.includes(id)) && listed.length === EXPECTED.length,
|
||||
'listed=' + listed.join(','));
|
||||
for (const id of EXPECTED) {
|
||||
const res = await page.request.get(BASE + '/assets/voice/' + id + '.mp3');
|
||||
const len = (await res.body()).length;
|
||||
const ctype = res.headers()['content-type'] || '';
|
||||
check('音频 ' + id, res.ok() && len > 1000 && /audio|mpeg|octet/.test(ctype),
|
||||
`status=${res.status()} bytes=${len} type=${ctype}`);
|
||||
}
|
||||
|
||||
// 2) mock 直达报告页:评分与精华卡有内容
|
||||
await page.goto(BASE + '/?mock=1&screen=report', { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(() => {
|
||||
const b = document.querySelector('#stat-score b');
|
||||
return b && b.textContent && b.textContent !== '--';
|
||||
}, null, { timeout: 8000 });
|
||||
const score = await page.locator('#stat-score b').innerText();
|
||||
const essence = await page.locator('#essence-name').innerText();
|
||||
const reportOn = await page.locator('#s-report.active').count();
|
||||
check('直达报告页', reportOn === 1 && /^\d/.test(score) && essence && essence !== '--',
|
||||
`score=${score} essence=${essence}`);
|
||||
|
||||
// 3) 完整 mock 流程:启动 → 档案 → 三连拍 → 报告,并记录语音起播
|
||||
await page.goto(BASE + '/?mock=1', { waitUntil: 'networkidle' });
|
||||
await page.getByRole('button', { name: '开始检测' }).click();
|
||||
await page.locator('#privacy-check').click();
|
||||
await page.getByRole('button', { name: '继续' }).click();
|
||||
await page.locator('#f-age').fill('28');
|
||||
await page.locator('#f-gender').selectOption('女');
|
||||
await page.locator('#rs-current').click();
|
||||
await page.locator('#rs-list li[data-v="北京"]').click();
|
||||
await page.getByRole('button', { name: '提交' }).click();
|
||||
await page.getByRole('button', { name: '开始' }).click();
|
||||
|
||||
await page.waitForFunction(() => {
|
||||
const v = document.querySelector('#cam-video');
|
||||
return v && v.videoWidth > 0 && document.querySelector('#s-cam').classList.contains('cam-live');
|
||||
}, null, { timeout: 12000 });
|
||||
const step1 = (await page.locator('#cam-step').innerText()).replace(/\s+/g, '');
|
||||
check('进入拍照 1/3', step1.includes('1/3') && step1.includes('正脸'), step1);
|
||||
|
||||
// 听完每步引导再按快门(最长 step2 4.3s):引导、「拍摄完成」、「分析中」都应完整播出
|
||||
// 步骤顺序与轮廓对齐 Figma:正脸 → 右脸(耳在画面右,mask 2)→ 左脸(耳在画面左,mask 1)
|
||||
const FIGMA_STEPS = [['正脸', '0'], ['右脸', '2'], ['左脸', '1']];
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const n = i + 1;
|
||||
await page.waitForFunction((num) => {
|
||||
const t = document.querySelector('#cam-step');
|
||||
return t && t.textContent.includes(num + '/3');
|
||||
}, n, { timeout: 8000 });
|
||||
const st = await page.evaluate(() => ({ text: document.querySelector('#cam-step').textContent, mask: document.querySelector('#cam-mask').getAttribute('data-step') }));
|
||||
check(`步骤 ${n}/3 文案与轮廓对齐 Figma`, st.text.includes(FIGMA_STEPS[i][0]) && st.mask === FIGMA_STEPS[i][1], `${st.text} mask=${st.mask}`);
|
||||
await page.waitForFunction((id) => (window.__voice || []).some((v) => v.id === id), `step${n}-${['front', 'rightface', 'leftface'][i]}`, { timeout: 8000 });
|
||||
await page.waitForTimeout(4600);
|
||||
await page.locator('[data-action="shutter"]').click();
|
||||
}
|
||||
|
||||
await page.waitForFunction(() => document.querySelector('#s-report').classList.contains('active'), null, { timeout: 15000 });
|
||||
const score2 = await page.locator('#stat-score b').innerText();
|
||||
const age = await page.locator('#stat-age b').innerText();
|
||||
check('三连拍进入报告', /^\d/.test(score2), `score=${score2} skinAge=${age}`);
|
||||
|
||||
await page.waitForTimeout(3200); // 等「分析中」播完
|
||||
const played = await page.evaluate(() => window.__voice || []);
|
||||
const ids = played.map((v) => v.id);
|
||||
console.log('VOICE 起播: ' + played.map((v) => `${v.id}(${v.dur}ms${v.cutAt != null ? ' 截断@' + v.cutAt : ''})`).join(' → '));
|
||||
for (const id of ['step1-front', 'step2-rightface', 'step3-leftface', 'shot-done', 'analyzing']) {
|
||||
check('流程播报 ' + id, ids.includes(id));
|
||||
}
|
||||
check('步骤引导顺序', ids.indexOf('step1-front') < ids.indexOf('step2-rightface')
|
||||
&& ids.indexOf('step2-rightface') < ids.indexOf('step3-leftface'));
|
||||
check('拍摄完成每步各一次', ids.filter((id) => id === 'shot-done').length === 3,
|
||||
'count=' + ids.filter((id) => id === 'shot-done').length);
|
||||
const cut = played.filter((v) => v.cutAt != null && v.cutAt < v.dur - 80);
|
||||
check('流程语音无截断', cut.length === 0, cut.map((v) => `${v.id}@${v.cutAt}/${v.dur}`).join(','));
|
||||
check('页面无脚本异常', pageErrors.length === 0, pageErrors.join(' | '));
|
||||
|
||||
await page.screenshot({ path: '/tmp/juzi-e2e-report.png', fullPage: true });
|
||||
console.log('截图 /tmp/juzi-e2e-report.png');
|
||||
} catch (err) {
|
||||
console.error('RUN ERROR', err);
|
||||
fails.push(String(err && err.message || err));
|
||||
try { await page.screenshot({ path: '/tmp/juzi-e2e-fail.png', fullPage: true }); } catch (e) {}
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
console.log(fails.length ? `\n${fails.length} 项失败` : '\n全部通过');
|
||||
process.exit(fails.length ? 1 : 0);
|
||||
+2
-2
@@ -36,8 +36,8 @@ INSTRUCT = (
|
||||
# (文件名不含扩展名, 文案) — 与设计文档第 2 节逐字一致
|
||||
LINES: list[tuple[str, str]] = [
|
||||
("step1-front", "请单人正脸出镜,面部对准取景框"),
|
||||
("step2-leftface", "请慢慢向右转头,露出左脸"),
|
||||
("step3-rightface", "请保持单人出镜,慢慢向左转头,露出右脸"),
|
||||
("step2-rightface", "请保持单人出镜,慢慢向左转头,露出右脸"),
|
||||
("step3-leftface", "请慢慢向右转头,露出左脸"),
|
||||
("hint-noface", "未检测到人脸,请正对镜头"),
|
||||
("hint-near", "请靠近一点"),
|
||||
("hint-far", "请远一点"),
|
||||
|
||||
Reference in New Issue
Block a user