Merge AI 跟练(训练分包)与血糖页体验优化

- 新增 AI 跟练:摄像头识别深蹲、高抬腿、开合跳、侧平举,自动计数与纠正;无摄像头时领练
- 居家耗糖训练改为轮播卡片,新增 AI 跟练入口
- 真机修复:识别坐标异常导致不计数/误计数、训练结束卡死、单帧跳动多计
- 血糖页 AI 咨询默认按住说话,松手发送、上滑取消;页面禁止左右滑动
- 修复聊天页真机调试编译失败
This commit is contained in:
2026-10-31 13:36:20 +08:00
270 changed files with 13957 additions and 907 deletions
+1 -1
View File
@@ -610,7 +610,7 @@ const { proxy } = getCurrentInstance()
patientLeaveNotified = false;
console.log('进入onMounted:', options);
const app = getApp();
const userID = options.userID|| app.globalData.userID;
let userID = options.userID|| app.globalData.userID;
if(!userID.includes('patient')){
userID='patient_'+userID
}
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -9,7 +9,8 @@
"dev:app": "UNI_INPUT_DIR=\"$PWD\" uni -p app",
"build:mp-weixin": "UNI_INPUT_DIR=\"$PWD\" uni build -p mp-weixin",
"build:h5": "UNI_INPUT_DIR=\"$PWD\" uni build",
"build:app": "UNI_INPUT_DIR=\"$PWD\" uni build -p app"
"build:app": "UNI_INPUT_DIR=\"$PWD\" uni build -p app",
"test:pose": "node --no-warnings --test \"training/pose/__tests__/*.test.ts\""
},
"keywords": [],
"author": "",
+17
View File
@@ -162,6 +162,23 @@
"navigationBarTextStyle": "black",
"backgroundColor": "#f8fafc"
}
},
{
"path": "pages/pose-hub",
"style": {
"navigationBarTitleText": "AI 跟练",
"navigationBarBackgroundColor": "#f4fbf4",
"navigationBarTextStyle": "black",
"backgroundColor": "#f4fbf4"
}
},
{
"path": "pages/pose-coach",
"style": {
"navigationStyle": "custom",
"disableScroll": true,
"backgroundColor": "#0b1210"
}
}
]
},
@@ -59,6 +59,7 @@ const ICON_PATHS = {
moon: '<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"/>',
sunset: '<path d="M12 10V2M18.364 5.636l-2.12 2.12M5.636 18.364l2.12-2.12M22 18h-3M5 18H2M18.364 18.364l-2.12-2.12M5.636 5.636l2.12 2.12M16 12a4 4 0 1 1-8 0 4 4 0 0 1 8 0Z"/>',
mic: '<path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" x2="12" y1="19" y2="22"/>',
keyboard: '<rect width="20" height="16" x="2" y="4" rx="2"/><path d="M6 8h.01M10 8h.01M14 8h.01M18 8h.01M8 12h.01M12 12h.01M16 12h.01M7 16h10"/>',
camera: '<path d="M14.5 4h-5L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-3l-2.5-3z"/><circle cx="12" cy="13" r="3"/>',
bulb: '<path d="M15 14c.2-1 .7-1.7 1.5-2.5 1-.9 1.5-2.2 1.5-3.5A6 6 0 0 0 6 8c0 1.3.5 2.6 1.5 3.5.8.8 1.3 1.5 1.5 2.5"/><path d="M9 18h6"/><path d="M10 22h4"/>',
leaf: '<path d="M11 20A7 7 0 0 1 9.8 6.1C15.5 5 17 4.48 19 2c1 2 2 4.18 2 8 0 5.5-4.78 10-10 10Z"/><path d="M2 21c0-3 1.85-5.36 5.08-6"/>',
+144 -61
View File
@@ -82,16 +82,16 @@
</view>
<view class="st-chart-area" @click="onChartTap">
<canvas
v-if="!recordOverlayOpen"
v-if="!chartHidden"
id="bloodWaveChart"
type="2d"
class="st-chart-canvas"
@click.stop="onChartTap"
/>
<view v-if="recordOverlayOpen" class="st-chart-placeholder">
<view v-if="chartHidden" class="st-chart-placeholder">
<text>图表已暂停显示</text>
</view>
<view v-if="!chartHasDisplayData && !loading && !recordOverlayOpen" class="st-chart-empty">
<view v-if="!chartHasDisplayData && !loading && !chartHidden" class="st-chart-empty">
<text>当前时间范围暂无血糖数据</text>
</view>
</view>
@@ -190,53 +190,13 @@
<TongjiIcon name="sparkles" size="sm" color="#ffffff" />
</view>
<!-- 底部浮动 AI 输入栏 -->
<!-- 底部浮动 AI 输入栏:默认按住说话(松手发送、上滑取消),右侧图标切换文字输入 -->
<view v-if="diagnosisId && !floatAskClosed" class="st-float-ask">
<view class="st-float-close" @click="floatAskClosed = true">
<text class="st-float-close-icon">×</text>
</view>
<view class="st-float-inner" :class="{ 'is-expanded': dietAiAskExpanded }">
<view class="st-float-icon">
<TongjiIcon name="sparkles" size="sm" color="#006c49" />
<view v-if="lastAskQuestion && (dietAiAsking || dietAiAskResult.advice)" class="st-float-result">
<view class="st-float-question">
<text class="st-float-question-label">你问</text>
<text class="st-float-question-text">{{ lastAskQuestion }}</text>
</view>
<textarea
class="st-float-input"
:value="dietAiAskText"
placeholder="输入或语音咨询 AI…"
placeholder-class="st-float-placeholder"
:maxlength="-1"
auto-height
:show-confirm-bar="false"
:disable-default-padding="true"
:cursor-spacing="24"
confirm-type="send"
:confirm-hold="false"
@input="onDietAiAskInput"
@confirm="askDietAiFood"
@focus="onDietAiAskFocus"
@blur="onDietAiAskBlur"
/>
<view
class="st-float-mic"
:class="{ listening: sttListening || sttHolding, disabled: dietAiAsking || !sttSupported }"
@touchstart.stop.prevent="onDietAiSpeechStart"
@touchend.stop.prevent="onDietAiSpeechEnd"
@touchcancel.stop.prevent="onDietAiSpeechEnd"
>
<TongjiIcon name="mic" size="sm" :color="(sttListening || sttHolding) ? '#ffffff' : '#006c49'" />
</view>
<view
class="st-float-send"
:class="{ disabled: dietAiAsking }"
@click="askDietAiFood"
>
<TongjiIcon name="send" size="sm" color="#ffffff" />
</view>
</view>
<view v-if="sttHolding && !dietAiAsking" class="st-float-hold-hint">
<text>{{ sttListening ? '松手结束' : '准备录音…' }}</text>
</view>
<view v-if="dietAiAsking || dietAiAskResult.advice" class="st-float-result">
<view v-if="dietAiAskResult.level_label" class="st-float-badge">
<text>{{ dietAiAskResult.level_label }}</text>
</view>
@@ -244,16 +204,68 @@
{{ dietAiAskResult.advice || (dietAiAsking ? '正在想…' : '') }}<text v-if="dietAiAsking" class="ai-stream-cursor">▍</text>
</text>
</view>
<view class="st-float-bar">
<view class="st-float-close" @click="floatAskClosed = true">
<text class="st-float-close-icon">×</text>
</view>
<view class="st-float-inner" :class="{ 'is-expanded': !voiceMode && dietAiAskExpanded }">
<view class="st-float-icon">
<TongjiIcon name="sparkles" size="sm" color="#006c49" />
</view>
<view
v-if="voiceMode"
class="st-hold-btn"
:class="{ holding: sttHolding, cancel: sttHolding && voiceCancel, disabled: dietAiAsking }"
@touchstart.stop.prevent="onVoiceTouchStart"
@touchmove.stop.prevent="onVoiceTouchMove"
@touchend.stop.prevent="onVoiceTouchEnd"
@touchcancel.stop.prevent="onVoiceTouchEnd"
>
<TongjiIcon name="mic" size="sm" :color="sttHolding ? '#ffffff' : '#006c49'" />
<text class="st-hold-text">{{ holdBtnText }}</text>
</view>
<textarea
v-else
class="st-float-input"
:value="dietAiAskText"
:focus="askInputFocus"
placeholder="问问 AI:能吃什么、吃多少…"
placeholder-class="st-float-placeholder"
:maxlength="-1"
auto-height
:show-confirm-bar="false"
:disable-default-padding="true"
:cursor-spacing="24"
confirm-type="send"
:confirm-hold="false"
@input="onDietAiAskInput"
@confirm="sendTextAsk"
@focus="onDietAiAskFocus"
@blur="onDietAiAskBlur"
/>
<view
v-if="!voiceMode && hasAskText"
class="st-float-send"
:class="{ disabled: dietAiAsking }"
@click="sendTextAsk"
>
<TongjiIcon name="send" size="sm" color="#ffffff" />
</view>
<view v-if="sttSupported" class="st-mode-toggle" @click="toggleAskMode">
<TongjiIcon :name="voiceMode ? 'keyboard' : 'mic'" size="sm" color="#3c4a42" />
</view>
</view>
</view>
</view>
<!-- 语音长按状态浮层:居中显示,避免被键盘/底栏遮挡 -->
<!-- 按住说话浮层:实时显示识别出的文字;手指上滑进入取消状态 -->
<view v-if="sttHolding && !dietAiAsking" class="st-voice-overlay">
<view class="st-voice-card">
<view class="st-voice-icon" :class="{ active: sttListening }">
<TongjiIcon name="mic" size="lg" color="#ffffff" />
<view class="st-voice-card" :class="{ cancel: voiceCancel }">
<view class="st-voice-wave" :class="{ active: sttListening && !voiceCancel }">
<view v-for="i in 5" :key="i" class="st-voice-bar" :style="{ animationDelay: i * 0.12 + 's' }" />
</view>
<text class="st-voice-title">{{ sttListening ? '正在聆听…' : '准备录音…' }}</text>
<text class="st-voice-sub">松开手指结束并识别</text>
<text class="st-voice-live">{{ voicePartial || (sttListening ? '请说,比如“晚饭能吃西瓜吗”' : '准备录音…') }}</text>
<text class="st-voice-sub">{{ voiceCancel ? '松开手指,取消发送' : '松开发送 · 上滑取消' }}</text>
</view>
</view>
@@ -535,6 +547,7 @@ function onDietAiAskFocus() {
}
function onDietAiAskBlur() {
dietAiAskFocused.value = false
askInputFocus.value = false
}
/** 聚焦或已有内容时展开为多行输入(内容多则自动增高) */
const dietAiAskExpanded = computed(
@@ -547,6 +560,20 @@ function onKeyboardHeightChange(res) {
keyboardHeight.value = res && res.height ? Math.round(res.height) : 0
}
/** 按住说话时手指上滑超过这个距离(px)进入取消状态 */
const VOICE_CANCEL_DY = 60
/** 输入方式:默认按住说话,右侧图标切到文字 */
const askMode = ref('voice')
const askInputFocus = ref(false)
/** 上滑取消:松手后丢弃这次识别结果 */
const voiceCancel = ref(false)
/** 说话过程中的实时识别文字 */
const voicePartial = ref('')
let voiceStartY = 0
/** 最近一次提问,显示在回答卡片上,方便核对语音有没有听错 */
const lastAskQuestion = ref('')
const {
sttListening,
sttHolding,
@@ -555,19 +582,73 @@ const {
endHoldSpeech,
stopSpeechToText
} = useSpeechToText({
onResult(text) {
const prev = String(dietAiAskText.value || '').trim()
dietAiAskText.value = prev ? `${prev} ${text}` : text
onPartial(text) {
voicePartial.value = text
},
onSettle(text) {
if (voiceCancel.value) {
voiceCancel.value = false
return true
}
// 没识别到内容:交给默认流程(按得太短不打扰,否则提示没听清)
if (!text) return false
submitAsk(text)
return true
},
showToast: (msg) => showUserToast(msg)
})
function onDietAiSpeechStart() {
/** 不支持语音的环境(如开发者工具)直接用文字输入 */
const voiceMode = computed(() => sttSupported.value && askMode.value === 'voice')
const hasAskText = computed(() => String(dietAiAskText.value || '').trim().length > 0)
const holdBtnText = computed(() => {
if (dietAiAsking.value) return 'AI 正在回答…'
if (!sttHolding.value) return '按住 说话'
return voiceCancel.value ? '松开 取消' : '松开 发送'
})
function submitAsk(text) {
const q = String(text || '').trim()
if (!q || dietAiAsking.value) return
lastAskQuestion.value = q
dietAiAskText.value = q
askDietAiFood()
}
function sendTextAsk() {
submitAsk(dietAiAskText.value)
}
function toggleAskMode() {
if (askMode.value === 'voice') {
askMode.value = 'text'
askInputFocus.value = true
return
}
askMode.value = 'voice'
askInputFocus.value = false
uni.hideKeyboard()
}
function touchY(e) {
const t = (e && e.touches && e.touches[0]) || (e && e.changedTouches && e.changedTouches[0])
return t ? Number(t.clientY ?? t.pageY ?? 0) : 0
}
function onVoiceTouchStart(e) {
if (dietAiAsking.value) return
voiceStartY = touchY(e)
voiceCancel.value = false
voicePartial.value = ''
beginHoldSpeech()
}
function onDietAiSpeechEnd() {
function onVoiceTouchMove(e) {
if (!sttHolding.value) return
voiceCancel.value = voiceStartY - touchY(e) > VOICE_CANCEL_DY
}
function onVoiceTouchEnd() {
endHoldSpeech()
}
@@ -1812,9 +1893,11 @@ function closeInputForm() {
/** 任一录入弹层打开时需卸载 canvas(小程序原生组件会盖住普通 view)*/
const recordOverlayOpen = computed(() => inputForm.value.visible || phoneGateVisible.value)
/** 2d 画布层级高于普通元素,会盖住弹层:录入弹窗、授权弹窗、按住说话浮层出现时先隐藏趋势图 */
const chartHidden = computed(() => recordOverlayOpen.value || sttHolding.value)
watch(recordOverlayOpen, (open) => {
if (!open) {
watch(chartHidden, (hidden) => {
if (!hidden) {
resetCanvasCache()
nextTick(() => scheduleChartRedraw())
}
+196 -115
View File
@@ -8,6 +8,10 @@
--st-shadow-float: 0 16rpx 64rpx rgba(0, 108, 73, 0.15);
--tab-dock-full-offset: calc(220rpx + env(safe-area-inset-bottom));
padding-bottom: var(--tab-dock-full-offset);
// 页面宽度固定:子元素横向超出一律裁掉,整页不能左右滑动。
// clip 不会生成滚动容器;不支持 clip 的旧内核忽略第二条,回退到 hidden
overflow-x: hidden;
overflow-x: clip;
}
/* ===== Header ===== */
@@ -700,7 +704,7 @@
text-align: center;
}
/* ===== 底部浮动输入 ===== */
/* ===== 底部浮动 AI 输入:默认按住说话,右侧切换文字 ===== */
.weekly-page .st-float-ask {
position: fixed;
left: var(--st-container-margin);
@@ -709,14 +713,18 @@
z-index: 210;
}
.weekly-page .st-float-bar {
position: relative;
}
.weekly-page .st-float-close {
position: absolute;
top: -18rpx;
top: -14rpx;
right: -6rpx;
width: 44rpx;
height: 44rpx;
width: 40rpx;
height: 40rpx;
border-radius: 50%;
background: rgba(0, 0, 0, 0.45);
background: rgba(22, 29, 25, 0.55);
display: flex;
align-items: center;
justify-content: center;
@@ -725,7 +733,7 @@
.weekly-page .st-float-close-icon {
color: #ffffff;
font-size: 30rpx;
font-size: 28rpx;
line-height: 1;
}
@@ -747,21 +755,21 @@
.weekly-page .st-float-inner {
display: flex;
align-items: center;
gap: 16rpx;
padding: 16rpx 16rpx 16rpx 28rpx;
gap: 14rpx;
padding: 12rpx;
border-radius: 999rpx;
background: rgba(255, 255, 255, 0.9);
background: rgba(255, 255, 255, 0.92);
backdrop-filter: blur(20rpx);
border: 1rpx solid rgba(0, 108, 73, 0.1);
box-shadow: var(--st-shadow-float);
transition: border-radius 0.22s ease, padding 0.22s ease, border-color 0.22s ease;
}
/* 聚焦/有内容时展开:底部对齐让输入框向上增高,圆角收敛为圆角矩形 */
/* 文字输入聚焦 / 有内容时展开:底部对齐让输入框向上增高,圆角收敛为圆角矩形 */
.weekly-page .st-float-inner.is-expanded {
align-items: flex-end;
border-radius: 32rpx;
padding: 18rpx 16rpx 18rpx 32rpx;
padding: 16rpx 12rpx 16rpx 16rpx;
border-color: rgba(0, 108, 73, 0.28);
}
@@ -776,6 +784,67 @@
flex-shrink: 0;
}
/* 按住说话:占满整条,按下变实心主色,上滑取消变红 */
.weekly-page .st-hold-btn {
flex: 1;
min-width: 0;
height: 80rpx;
border-radius: 999rpx;
display: flex;
align-items: center;
justify-content: center;
gap: 12rpx;
background: linear-gradient(180deg, #f3faf6 0%, #e2f2e9 100%);
border: 1rpx solid rgba(0, 108, 73, 0.16);
transition: background 0.18s ease, transform 0.15s ease, box-shadow 0.18s ease;
}
.weekly-page .st-hold-text {
font-size: 30rpx;
font-weight: 700;
letter-spacing: 4rpx;
color: var(--primary);
line-height: 1;
}
.weekly-page .st-hold-btn.holding {
background: linear-gradient(180deg, #1f8a5c 0%, #006c49 100%);
border-color: transparent;
box-shadow: 0 8rpx 24rpx rgba(0, 108, 73, 0.3);
transform: scale(0.98);
}
.weekly-page .st-hold-btn.cancel {
background: linear-gradient(180deg, #e5484d 0%, #ba1a1a 100%);
box-shadow: 0 8rpx 24rpx rgba(186, 26, 26, 0.3);
}
.weekly-page .st-hold-btn.holding .st-hold-text {
color: #ffffff;
}
.weekly-page .st-hold-btn.disabled {
opacity: 0.55;
pointer-events: none;
}
.weekly-page .st-mode-toggle {
width: 72rpx;
height: 72rpx;
border-radius: 50%;
background: var(--surface-container-low);
border: 1rpx solid rgba(0, 108, 73, 0.1);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: transform 0.15s ease;
}
.weekly-page .st-mode-toggle:active {
transform: scale(0.92);
}
.weekly-page .st-float-input {
flex: 1;
min-width: 0;
@@ -797,7 +866,7 @@
}
.weekly-page .st-float-placeholder {
color: rgba(60, 74, 66, 0.6);
color: rgba(60, 74, 66, 0.55);
font-size: 26rpx;
line-height: 40rpx;
white-space: nowrap;
@@ -805,106 +874,10 @@
text-overflow: ellipsis;
}
.weekly-page .st-float-mic {
.weekly-page .st-float-send {
width: 72rpx;
height: 72rpx;
border-radius: 50%;
background: rgba(0, 108, 73, 0.1);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: background 0.2s ease, transform 0.15s ease;
}
.weekly-page .st-float-mic.listening {
background: var(--primary);
animation: st-float-mic-pulse 1.2s ease-in-out infinite;
}
.weekly-page .st-float-mic.disabled {
opacity: 0.45;
pointer-events: none;
}
.weekly-page .st-float-mic:active {
transform: scale(0.94);
}
.weekly-page .st-float-hold-hint {
margin-top: 12rpx;
text-align: center;
font-size: 24rpx;
color: var(--primary);
line-height: 1.4;
}
.weekly-page .st-voice-overlay {
position: fixed;
left: 0;
right: 0;
top: 0;
bottom: 0;
z-index: 3000;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.32);
pointer-events: none;
}
.weekly-page .st-voice-card {
display: flex;
flex-direction: column;
align-items: center;
gap: 20rpx;
padding: 56rpx 64rpx;
border-radius: 32rpx;
background: rgba(20, 24, 22, 0.86);
box-shadow: 0 16rpx 48rpx rgba(0, 0, 0, 0.35);
}
.weekly-page .st-voice-icon {
width: 120rpx;
height: 120rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(180deg, #2d7340 0%, #006c49 100%);
}
.weekly-page .st-voice-icon.active {
animation: st-voice-pulse 1.1s ease-in-out infinite;
}
@keyframes st-voice-pulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(0, 108, 73, 0.45); }
50% { box-shadow: 0 0 0 20rpx rgba(0, 108, 73, 0); }
}
.weekly-page .st-voice-title {
font-size: 32rpx;
font-weight: 700;
color: #ffffff;
line-height: 1.3;
}
.weekly-page .st-voice-sub {
font-size: 24rpx;
color: rgba(255, 255, 255, 0.7);
line-height: 1.3;
}
@keyframes st-float-mic-pulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(0, 108, 73, 0.35); }
50% { box-shadow: 0 0 0 12rpx rgba(0, 108, 73, 0); }
}
.weekly-page .st-float-send {
width: 80rpx;
height: 80rpx;
border-radius: 50%;
background: var(--primary);
display: flex;
align-items: center;
@@ -923,13 +896,121 @@
transform: scale(0.95);
}
/* 按住说话浮层:居中显示,避免被键盘/底栏遮挡;不拦截触摸,手指始终在按钮上 */
.weekly-page .st-voice-overlay {
position: fixed;
left: 0;
right: 0;
top: 0;
bottom: 0;
z-index: 3000;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.36);
pointer-events: none;
}
.weekly-page .st-voice-card {
width: 540rpx;
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: center;
gap: 24rpx;
padding: 48rpx 40rpx 40rpx;
border-radius: 40rpx;
background: rgba(20, 24, 22, 0.9);
box-shadow: 0 16rpx 48rpx rgba(0, 0, 0, 0.35);
transition: background 0.18s ease;
}
.weekly-page .st-voice-card.cancel {
background: rgba(186, 26, 26, 0.92);
}
.weekly-page .st-voice-wave {
height: 72rpx;
display: flex;
align-items: center;
gap: 12rpx;
}
.weekly-page .st-voice-bar {
width: 10rpx;
height: 24rpx;
border-radius: 6rpx;
background: #4edea3;
}
.weekly-page .st-voice-card.cancel .st-voice-bar {
background: rgba(255, 255, 255, 0.85);
}
.weekly-page .st-voice-wave.active .st-voice-bar {
animation: st-voice-wave 0.9s ease-in-out infinite;
}
@keyframes st-voice-wave {
0%, 100% { transform: scaleY(0.5); }
50% { transform: scaleY(2.4); }
}
.weekly-page .st-voice-live {
min-height: 92rpx;
font-size: 32rpx;
font-weight: 600;
line-height: 1.45;
color: #ffffff;
text-align: center;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.weekly-page .st-voice-sub {
font-size: 24rpx;
color: rgba(255, 255, 255, 0.72);
line-height: 1.3;
}
/* 回答卡片:在输入栏上方,先显示“你问”方便核对语音识别 */
.weekly-page .st-float-result {
margin-top: 16rpx;
padding: 24rpx 32rpx;
border-radius: var(--st-radius-xl);
background: rgba(255, 255, 255, 0.95);
margin-bottom: 16rpx;
max-height: 42vh;
overflow-y: auto;
padding: 24rpx 28rpx;
border-radius: 32rpx;
background: rgba(255, 255, 255, 0.97);
border: 1rpx solid var(--outline-variant);
box-shadow: var(--st-shadow-ambient);
box-shadow: var(--st-shadow-float);
}
.weekly-page .st-float-question {
display: flex;
align-items: flex-start;
gap: 12rpx;
margin-bottom: 14rpx;
}
.weekly-page .st-float-question-label {
flex-shrink: 0;
padding: 2rpx 12rpx;
border-radius: 8rpx;
font-size: 22rpx;
font-weight: 700;
color: var(--on-surface-variant);
background: var(--surface-container);
line-height: 1.6;
}
.weekly-page .st-float-question-text {
flex: 1;
min-width: 0;
font-size: 26rpx;
line-height: 1.6;
color: var(--on-surface-variant);
}
.weekly-page .st-float-badge {
@@ -946,7 +1027,7 @@
.weekly-page .st-float-advice {
font-size: 28rpx;
line-height: 1.5;
color: var(--on-surface-variant);
color: var(--on-surface);
}
.weekly-page .ai-stream-cursor {
@@ -0,0 +1,123 @@
<template>
<view class="meter" :class="['is-' + theme, { 'is-reached': reached }]">
<view class="meter__head">
<text class="meter__label">动作幅度</text>
<text class="meter__state">{{ reached ? '到位' : hint }}</text>
</view>
<view class="meter__track">
<view class="meter__fill" :style="{ width: pct + '%', background: reached ? color : undefined }" />
<view class="meter__goal" :style="{ left: goal * 100 + '%' }" />
</view>
</view>
</template>
<script setup lang="ts">
/**
* 动作幅度条:代替骨架给出实时反馈。条随动作伸缩,越过目标线(计一次所需的幅度)即变色。
*/
import { computed } from 'vue'
const props = withDefaults(
defineProps<{
/** 当前动作幅度 0~1 */
amount: number
/** 计一次所需的幅度 0~1 */
goal: number
color?: string
theme?: 'light' | 'dark'
/** 未到位时右侧的提示 */
hint?: string
}>(),
{ color: '#34d399', theme: 'light', hint: '再深一点' },
)
const pct = computed(() => Math.round(Math.min(1, Math.max(0, props.amount)) * 100))
const reached = computed(() => props.amount >= props.goal)
</script>
<style lang="scss" scoped>
.meter {
width: 100%;
}
.meter__head {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: 10rpx;
}
.meter__label {
font-size: 24rpx;
font-weight: 600;
}
.meter__state {
font-size: 24rpx;
font-weight: 700;
}
.is-light {
.meter__label {
color: #6b7a72;
}
.meter__state {
color: #9aa7a0;
}
.meter__track {
background: rgba(22, 29, 25, 0.08);
}
.meter__fill {
background: #b8c4be;
}
.meter__goal {
background: #161d19;
}
}
.is-dark {
.meter__label {
color: rgba(255, 255, 255, 0.75);
}
.meter__state {
color: rgba(255, 255, 255, 0.6);
}
.meter__track {
background: rgba(255, 255, 255, 0.18);
}
.meter__fill {
background: rgba(255, 255, 255, 0.55);
}
.meter__goal {
background: #fff;
}
}
.is-reached .meter__state {
color: #10b981 !important;
}
.meter__track {
position: relative;
height: 22rpx;
border-radius: 999rpx;
}
.meter__fill {
position: absolute;
left: 0;
top: 0;
bottom: 0;
border-radius: 999rpx;
transition: width 0.12s linear;
}
.meter__goal {
position: absolute;
top: -8rpx;
bottom: -8rpx;
width: 6rpx;
margin-left: -3rpx;
border-radius: 3rpx;
}
</style>
@@ -0,0 +1,229 @@
<template>
<view class="coach" :class="{ 'is-breathing': breathing }">
<view v-if="shadow" class="coach__shadow" />
<!-- 按宽度取正方形、垂直居中:和帧图等比缩放后的位置完全重合,包内帧和高清帧叠在同一处 -->
<view class="coach__stage">
<block v-if="!hdReady">
<image
v-for="f in frames"
:key="f"
class="coach__frame"
:class="{ 'is-on': f === current }"
:src="frameUrl(f)"
mode="aspectFit"
/>
</block>
<view v-if="hdFrames.length" class="coach__hd" :style="hdBox">
<image
v-for="f in hdFrames"
:key="'hd-' + f"
class="coach__frame"
:class="{ 'is-on': hdReady && f === current }"
:src="hdFrameUrl(hdBase, f)"
:webp="true"
mode="scaleToFill"
@load="onHdLoad"
@error="onHdError"
/>
</view>
</view>
</view>
</template>
<script setup lang="ts">
/**
* 教练“小甄”逐帧动画
*
* 动作帧是 AI 原图加少量插帧(每侧 10~24 帧,按实测幅度查帧),全部以 <image> 叠放,只切换当前显示的那一张:
* 不用 canvas —— 小程序里 canvas 是同层渲染组件,放在绝对定位、圆角裁剪的卡片里时,
* 页面切换后可能画到卡片外面;<image> 在任何布局下都可靠,也不需要逐帧重绘。
*
* mode:
* - demo 按示范节奏循环(speed 调快慢,paused 时站立待机、轻微呼吸)
* - mirror 跟随用户实时动作幅度 amount / side
* - gesture 挥手 / 欢呼 / 点赞
* 示范模式下教练每做完一次发出 rep 事件(领练模式据此计数,报数和画面同步)。
* 动作中不插入欢呼 / 弹跳:和动作节奏对不上的突变,看起来就是“闪一下”。
*
* 清晰度:包内帧 600px(分包有 2MB 上限),配置了 COS 时再加载 1000px 高清帧(只裁出人物区域,控制解码内存)。
* 包内帧先播,高清帧在后台全部加载完才切过去并卸载包内帧;任何一张加载失败就一直用包内帧,不会空白。
*/
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { MOTIONS, createRepEdge, type MoveId, type Side } from '../../pose/motions'
import { crispBlend, framePair, framesToLoad, frameUrl, hdCropOf, hdFrameUrl, sequenceOf, type Gesture } from '../../pose/coachFrames'
import { coachCdnBase } from '../../pose/exercises'
const props = withDefaults(
defineProps<{
move?: MoveId
mode?: 'demo' | 'mirror' | 'gesture'
gesture?: Gesture
amount?: number
side?: Side
speed?: number
paused?: boolean
shadow?: boolean
}>(),
{
move: 'squat',
mode: 'demo',
gesture: 'wave',
amount: 0,
side: 'L',
speed: 1,
paused: false,
shadow: true,
},
)
const emit = defineEmits<{ (e: 'phase', label: string): void; (e: 'rep'): void }>()
const TICK_MS = 33
const hdBase = coachCdnBase(process.env.NODE_ENV === 'development')
const frames = computed(() => (props.mode === 'gesture' ? [props.gesture] : framesToLoad(props.move)))
const current = ref('')
const breathing = computed(() => props.mode === 'gesture' || (props.mode === 'demo' && props.paused))
// ───────── 高清帧 ─────────
const hdLoaded = ref(0)
const hdFailed = ref(false)
const hdFrames = computed(() => (hdBase && props.mode !== 'gesture' && !hdFailed.value ? framesToLoad(props.move) : []))
const hdReady = computed(() => hdFrames.value.length > 0 && hdLoaded.value >= hdFrames.value.length)
/** 裁过的高清帧放回它在整张画布上的位置 */
const hdBox = computed(() => {
const [x0, y0, x1, y1] = hdCropOf(props.move)
return { left: x0 * 100 + '%', top: y0 * 100 + '%', width: (x1 - x0) * 100 + '%', height: (y1 - y0) * 100 + '%' }
})
function onHdLoad() {
hdLoaded.value += 1
}
function onHdError() {
hdFailed.value = true
}
let timer: ReturnType<typeof setInterval> | null = null
let last = 0
let phase = 0
let smooth = 0
let lastLabel = ''
let repEdge = createRepEdge()
/** 只在帧真正变化时更新(每秒约 10~12 次),减少视图层通信 */
function show(name: string) {
if (name !== current.value) current.value = name
}
function pick(move: MoveId, amount: number, side: Side) {
const p = framePair(move, amount, side)
return crispBlend(p.w) < 0.5 ? p.a : p.b
}
function tick() {
const now = Date.now()
const dt = last ? Math.min(100, now - last) : 0
last = now
if (props.mode === 'gesture') return show(props.gesture)
if (props.mode === 'mirror') {
// 识别约 10fps:先对动作幅度做指数平滑,再取对应的帧,避免教练随识别抖动
smooth += (props.amount - smooth) * 0.35
return show(pick(props.move, smooth, props.side))
}
if (props.paused) return show(sequenceOf(props.move, 'L')[0])
const spec = MOTIONS[props.move]
phase += (dt / 1000 / spec.cycleSeconds) * props.speed
const label = spec.phaseLabel(phase)
if (label !== lastLabel) {
lastLabel = label
emit('phase', label)
}
const a = spec.amountAt(phase)
if (repEdge(a.amount)) emit('rep')
show(pick(props.move, a.amount, a.side))
}
watch(
() => [props.move, props.mode, props.gesture],
() => {
phase = 0
smooth = 0
repEdge = createRepEdge()
hdLoaded.value = 0
hdFailed.value = false
},
)
onMounted(() => {
tick()
timer = setInterval(tick, TICK_MS)
})
onBeforeUnmount(() => {
if (timer) clearInterval(timer)
})
</script>
<style lang="scss" scoped>
.coach {
position: relative;
width: 100%;
height: 100%;
}
.coach__shadow {
position: absolute;
left: 30%;
right: 30%;
bottom: 2.5%;
height: 5%;
border-radius: 50%;
background: radial-gradient(ellipse at center, rgba(15, 23, 42, 0.2) 0%, rgba(15, 23, 42, 0) 70%);
}
// 容器都是“宽 ≤ 高”:取宽度为边长的正方形、垂直居中(竖直方向的百分比外边距按宽度计算)
.coach__stage {
position: absolute;
left: 0;
top: 50%;
width: 100%;
height: 0;
padding-bottom: 100%;
margin-top: -50%;
transform-origin: 50% 95%;
}
.coach__hd {
position: absolute;
}
.coach__frame {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
opacity: 0;
}
.coach__frame.is-on {
opacity: 1;
}
.is-breathing .coach__stage {
animation: coach-breathe 2.8s ease-in-out infinite;
}
@keyframes coach-breathe {
0%,
100% {
transform: scale(1);
}
50% {
transform: scale(1.008);
}
}
</style>
@@ -0,0 +1,769 @@
// AI 跟练页(跟练阶段)样式
//
// 训练中以教练示范为主画面(亮色舞台),自己的摄像头画面缩在右上角小窗;
// 站位、离开画面或手动切换时自己的画面铺满,界面换成深色半透明面板(.is-dark)。
// 层级:舞台 0 < 全屏画面 1 < 渐变遮罩 2 < 小窗 5 < 界面 10 < 暂停弹层 20
$glass: rgba(15, 23, 20, 0.55);
$glass-strong: rgba(15, 23, 20, 0.72);
$mint: #34d399;
$ink: #161d19;
$ink-soft: #6b7a72;
.page {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: #0b1210;
overflow: hidden;
}
.cam-stage {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
}
// ───────── 教练舞台 ─────────
.stage {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 0;
background-color: #f4f8f6;
}
.coach-box {
position: absolute;
}
.coach-box.is-stage {
z-index: 0;
}
// 看自己时,教练缩到右上角
.coach-box.is-card {
right: 24rpx;
z-index: 5;
width: 216rpx;
height: 288rpx;
border-radius: 28rpx;
border: 4rpx solid #fff;
background: linear-gradient(180deg, #ffffff, #ecfdf5);
box-shadow: 0 16rpx 40rpx rgba(0, 0, 0, 0.3);
overflow: hidden;
}
.shade-top,
.shade-bottom {
position: absolute;
left: 0;
right: 0;
z-index: 2;
pointer-events: none;
}
.shade-top {
top: 0;
height: 260rpx;
background: linear-gradient(180deg, rgba(0, 0, 0, 0.55), rgba(0, 0, 0, 0));
}
.shade-bottom {
bottom: 0;
height: 460rpx;
background: linear-gradient(0deg, rgba(0, 0, 0, 0.65), rgba(0, 0, 0, 0));
}
// ───────── 介绍页返回 ─────────
.intro-back {
position: fixed;
left: 24rpx;
z-index: 20;
width: 64rpx;
height: 64rpx;
border-radius: 50%;
background: rgba(255, 255, 255, 0.85);
box-shadow: 0 4rpx 16rpx rgba(22, 29, 25, 0.1);
display: flex;
align-items: center;
justify-content: center;
}
.intro-back__arrow {
width: 18rpx;
height: 18rpx;
margin-left: 6rpx;
border-left: 4rpx solid $ink;
border-bottom: 4rpx solid $ink;
transform: rotate(45deg);
}
// ───────── 顶栏 ─────────
.topbar {
position: absolute;
left: 0;
right: 0;
top: 0;
z-index: 10;
box-sizing: content-box;
display: flex;
align-items: center;
justify-content: space-between;
padding-left: 24rpx;
padding-right: 210rpx; // 避开胶囊按钮
}
.icon-btn {
position: relative;
width: 64rpx;
height: 64rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
&.is-off {
opacity: 0.55;
}
}
// 右侧按钮组:背景音乐 ♪ + 教练语音
.topbar__actions {
display: flex;
align-items: center;
.icon-btn + .icon-btn {
margin-left: 16rpx;
}
}
.icon-btn__note {
font-size: 34rpx;
line-height: 1;
}
// 背景音乐关闭:♪ 上加一道斜线
.icon-btn--music.is-off::after {
content: '';
position: absolute;
left: 14rpx;
right: 14rpx;
top: 50%;
height: 4rpx;
margin-top: -2rpx;
border-radius: 2rpx;
transform: rotate(-45deg);
}
.icon-btn__x {
font-size: 44rpx;
line-height: 1;
margin-top: -4rpx;
}
.icon-btn__label {
font-size: 24rpx;
font-weight: 700;
}
.topbar__center {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
}
.topbar__title {
font-size: 28rpx;
font-weight: 700;
}
.topbar__time {
font-size: 24rpx;
font-variant-numeric: tabular-nums;
}
.is-light {
.icon-btn {
background: rgba(255, 255, 255, 0.85);
box-shadow: 0 4rpx 16rpx rgba(22, 29, 25, 0.08);
}
.icon-btn__x,
.icon-btn__label,
.icon-btn__note,
.topbar__title {
color: $ink;
}
.icon-btn--music.is-off::after {
background: $ink;
}
.topbar__time {
color: $ink-soft;
}
}
.is-dark {
.icon-btn {
background: $glass;
}
.icon-btn__x,
.icon-btn__label,
.icon-btn__note,
.topbar__title {
color: #fff;
}
.icon-btn--music.is-off::after {
background: #fff;
}
.topbar__time {
color: rgba(255, 255, 255, 0.8);
}
}
// ───────── 计数环 + 连击 ─────────
.ring-slot {
position: absolute;
left: 28rpx;
z-index: 10;
display: flex;
flex-direction: column;
align-items: center;
}
.combo {
margin-top: 16rpx;
display: flex;
align-items: baseline;
gap: 6rpx;
padding: 8rpx 22rpx;
border-radius: 999rpx;
background: linear-gradient(135deg, #f59e0b, #ef4444);
box-shadow: 0 8rpx 24rpx rgba(239, 68, 68, 0.35);
animation: combo-in 0.45s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.combo__n {
color: #fff;
font-size: 36rpx;
font-weight: 900;
font-style: italic;
}
.combo__t {
color: #fff;
font-size: 22rpx;
font-weight: 700;
}
@keyframes combo-in {
0% {
transform: scale(0.4);
opacity: 0;
}
100% {
transform: scale(1);
opacity: 1;
}
}
// ───────── 教练模式切换 ─────────
.mode-switch {
position: absolute;
right: 24rpx;
z-index: 10;
width: 216rpx;
box-sizing: border-box;
display: flex;
padding: 4rpx;
border-radius: 999rpx;
text {
flex: 1;
text-align: center;
font-size: 22rpx;
padding: 6rpx 0;
border-radius: 999rpx;
}
.is-on {
font-weight: 700;
}
}
.is-light .mode-switch {
background: rgba(22, 29, 25, 0.06);
text {
color: $ink-soft;
}
.is-on {
background: #fff;
color: #006c49;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.08);
}
}
.is-dark .mode-switch {
background: $glass;
text {
color: rgba(255, 255, 255, 0.75);
}
.is-on {
background: #fff;
color: #006c49;
}
}
// ───────── 中央:倒计时 / 离开画面 ─────────
.center {
position: absolute;
left: 0;
right: 0;
top: 30%;
z-index: 10;
display: flex;
flex-direction: column;
align-items: center;
pointer-events: none;
}
// 倒计时时给画面罩一层,数字不和教练重叠得看不清
.scrim {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 9;
}
.is-light .scrim {
background: rgba(244, 248, 246, 0.72);
}
.is-dark .scrim {
background: rgba(0, 0, 0, 0.35);
}
.countdown {
display: flex;
flex-direction: column;
align-items: center;
}
.countdown__n {
font-size: 280rpx;
line-height: 1;
font-weight: 900;
animation: count-pop 0.9s ease-out;
}
.countdown__t {
margin-top: 12rpx;
font-size: 34rpx;
font-weight: 700;
letter-spacing: 4rpx;
}
.is-light {
.countdown__n {
color: var(--accent);
}
.countdown__t {
color: $ink;
}
}
.is-dark {
.countdown__n {
color: #fff;
text-shadow: 0 8rpx 40rpx rgba(0, 0, 0, 0.45);
}
.countdown__t {
color: #fff;
}
}
@keyframes count-pop {
0% {
transform: scale(1.8);
opacity: 0;
}
30% {
transform: scale(1);
opacity: 1;
}
100% {
transform: scale(0.94);
}
}
.lost {
display: flex;
flex-direction: column;
align-items: center;
padding: 32rpx 56rpx;
border-radius: 36rpx;
background: $glass-strong;
}
.lost__title {
color: #fff;
font-size: 36rpx;
font-weight: 800;
}
.lost__sub {
margin-top: 8rpx;
color: rgba(255, 255, 255, 0.8);
font-size: 26rpx;
}
// ───────── 评分弹出 ─────────
.pop {
position: absolute;
left: 0;
right: 0;
z-index: 10;
display: flex;
justify-content: center;
align-items: baseline;
gap: 12rpx;
pointer-events: none;
animation: pop-up 1.1s ease-out forwards;
}
.pop__text {
font-size: 72rpx;
font-weight: 900;
font-style: italic;
}
.pop__score {
font-size: 40rpx;
font-weight: 800;
color: #10b981;
}
.is-light {
.pop__text {
color: var(--accent);
text-shadow: 0 4rpx 18rpx rgba(255, 255, 255, 0.9);
}
.pop--perfect .pop__text {
color: #f59e0b;
}
}
.is-dark {
.pop__text {
color: #fff;
text-shadow: 0 6rpx 24rpx rgba(0, 0, 0, 0.4);
}
.pop__score {
color: $mint;
}
.pop--perfect .pop__text {
color: #fde68a;
}
}
.pop--ok .pop__score {
color: #fbbf24 !important;
}
@keyframes pop-up {
0% {
transform: translateY(40rpx) scale(0.6);
opacity: 0;
}
20% {
transform: translateY(0) scale(1.1);
opacity: 1;
}
35% {
transform: scale(1);
}
80% {
opacity: 1;
}
100% {
transform: translateY(-60rpx);
opacity: 0;
}
}
// ───────── 底部栏 ─────────
.dock {
position: absolute;
left: 28rpx;
right: 28rpx;
bottom: calc(env(safe-area-inset-bottom) + 28rpx);
z-index: 10;
display: flex;
flex-direction: column;
align-items: center;
}
.framing {
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: 12rpx;
}
.framing__ring {
width: 120rpx;
height: 120rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.framing__ring-inner {
width: 100rpx;
height: 100rpx;
border-radius: 50%;
background: $glass-strong;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 24rpx;
font-weight: 700;
}
.framing__tip {
margin-top: 20rpx;
padding: 16rpx 32rpx;
border-radius: 999rpx;
background: $glass-strong;
color: #fff;
font-size: 30rpx;
font-weight: 600;
}
.tip {
display: flex;
align-items: center;
gap: 14rpx;
margin-bottom: 24rpx;
padding: 18rpx 30rpx;
border-radius: 999rpx;
background: #fbbf24;
box-shadow: 0 10rpx 30rpx rgba(251, 191, 36, 0.4);
animation: tip-in 0.35s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.tip__icon {
width: 40rpx;
height: 40rpx;
line-height: 40rpx;
border-radius: 50%;
background: $ink;
color: #fbbf24;
text-align: center;
font-size: 26rpx;
font-weight: 900;
}
.tip__text {
color: $ink;
font-size: 32rpx;
font-weight: 800;
}
@keyframes tip-in {
0% {
transform: translateY(20rpx) scale(0.8);
opacity: 0;
}
100% {
transform: none;
opacity: 1;
}
}
// 教练口令(“吸气 · 屁股向后坐”):离得远也要看清
.caption {
margin-bottom: 24rpx;
padding: 14rpx 34rpx;
border-radius: 999rpx;
font-size: 32rpx;
font-weight: 700;
}
.is-light .caption {
background: #fff;
color: $ink;
box-shadow: 0 8rpx 24rpx rgba(22, 29, 25, 0.08);
}
.is-dark .caption {
background: $glass;
color: #fff;
}
.dock__row {
width: 100%;
box-sizing: border-box;
display: flex;
align-items: center;
gap: 28rpx;
padding: 22rpx 22rpx 22rpx 32rpx;
border-radius: 36rpx;
}
.is-light .dock__row {
background: rgba(255, 255, 255, 0.9);
box-shadow: 0 12rpx 36rpx rgba(22, 29, 25, 0.1);
}
.is-dark .dock__row {
background: $glass-strong;
}
.dock__meter {
flex: 1;
}
.dock__guided {
flex: 1;
font-size: 26rpx;
font-weight: 600;
color: $ink-soft;
}
.dock__pause {
width: 88rpx;
height: 88rpx;
flex-shrink: 0;
border-radius: 50%;
background: var(--accent);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 8rpx 20rpx rgba(22, 29, 25, 0.18);
}
.pause-icon {
width: 24rpx;
height: 30rpx;
border-left: 8rpx solid #fff;
border-right: 8rpx solid #fff;
box-sizing: border-box;
}
// ───────── 暂停弹层 ─────────
.mask {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 20;
background: rgba(0, 0, 0, 0.55);
display: flex;
align-items: center;
justify-content: center;
}
.dialog {
width: 600rpx;
padding: 48rpx 40rpx 32rpx;
border-radius: 40rpx;
background: #fff;
display: flex;
flex-direction: column;
align-items: center;
}
.dialog__title {
font-size: 40rpx;
font-weight: 800;
color: $ink;
}
.dialog__sub {
margin: 12rpx 0 36rpx;
font-size: 26rpx;
color: #6c7a71;
}
.dialog__btn {
width: 100%;
height: 92rpx;
border-radius: 46rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 30rpx;
font-weight: 700;
color: #3c4a42;
margin-top: 16rpx;
&.is-primary {
background: linear-gradient(135deg, #10b981, #006c49);
color: #fff;
}
}
// ───────── 示范视频 ─────────
.video-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 100;
background: #000;
}
.video {
width: 100%;
height: 100%;
}
.video-close {
position: absolute;
left: 24rpx;
width: 64rpx;
height: 64rpx;
border-radius: 50%;
background: rgba(255, 255, 255, 0.2);
color: #fff;
font-size: 44rpx;
display: flex;
align-items: center;
justify-content: center;
}
/* 开发版识别诊断面板:底部操作栏上方,不挡计数环和小窗,不拦截点击 */
.diag {
position: absolute;
left: 16rpx;
right: 16rpx;
bottom: calc(270rpx + env(safe-area-inset-bottom));
z-index: 30;
padding: 10rpx 14rpx;
border-radius: 12rpx;
background: rgba(0, 0, 0, 0.62);
pointer-events: none;
}
.diag__text {
color: #a7f3d0;
font-size: 19rpx;
line-height: 1.45;
white-space: pre-wrap;
word-break: break-all;
}
@@ -0,0 +1,430 @@
<template>
<view class="intro" :style="{ '--accent': exercise.color, '--accent-soft': exercise.color + '1f', '--accent-mid': exercise.color + '33' }">
<scroll-view scroll-y class="intro__scroll">
<view class="stage" :style="{ paddingTop: navHeight + 'px' }">
<view class="stage__glow" />
<view class="stage__mascot">
<CoachSprite :move="exercise.id" mode="demo" @phase="phaseLabel = $event" />
</view>
<view class="stage__phase">
<view class="stage__phase-dot" />
<text>{{ phaseLabel || '跟着小甄一起做' }}</text>
</view>
<view v-if="videoUrl" class="stage__video" @click="emit('video')">
<view class="stage__play" />
<text>完整示范</text>
</view>
</view>
<view class="sheet">
<view class="sheet__head">
<text class="sheet__name">{{ exercise.name }}</text>
<view class="level">
<view v-for="i in 3" :key="i" class="level__bar" :class="{ 'is-on': i <= exercise.level }" />
<text class="level__text">{{ levelText }}</text>
</view>
</view>
<text class="sheet__tagline">{{ exercise.tagline }}</text>
<view class="chips">
<text v-for="t in exercise.targets" :key="t" class="chip">{{ t }}</text>
</view>
<view class="block">
<text class="block__title">动作要领</text>
<view v-for="(k, i) in exercise.keyPoints" :key="k" class="point">
<text class="point__no">{{ i + 1 }}</text>
<text class="point__text">{{ k }}</text>
</view>
</view>
<view class="block target">
<view class="target__info">
<text class="block__title">目标次数</text>
<text class="target__hint">约 {{ minutes }} 分钟 · {{ exercise.repStep }} 次一档</text>
</view>
<view class="stepper">
<view class="stepper__btn" :class="{ 'is-disabled': target <= exercise.repRange[0] }" @click="change(-1)">
<text>−</text>
</view>
<text class="stepper__value">{{ target }}</text>
<view class="stepper__btn" :class="{ 'is-disabled': target >= exercise.repRange[1] }" @click="change(1)">
<text>+</text>
</view>
</view>
</view>
<view class="block tips">
<text class="block__title">开始前</text>
<view class="tips__grid">
<view v-for="tip in TIPS" :key="tip.t" class="tips__item">
<text class="tips__t">{{ tip.t }}</text>
<text class="tips__d">{{ tip.d }}</text>
</view>
</view>
</view>
<view class="intro__spacer" />
</view>
</scroll-view>
<view class="cta">
<view class="cta__btn" @click="emit('start')">
<text class="cta__text">开始 AI 跟练</text>
</view>
<text class="cta__privacy">动作识别在手机本地完成,画面不会上传</text>
</view>
</view>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import CoachSprite from './coach-sprite.vue'
import { MOTIONS } from '../../pose/motions'
import type { ExerciseInfo } from '../../pose/exercises'
const props = defineProps<{ exercise: ExerciseInfo; target: number; navHeight: number; videoUrl: string }>()
const emit = defineEmits<{ (e: 'update:target', v: number): void; (e: 'start'): void; (e: 'video'): void }>()
const TIPS = [
{ t: '手机竖放', d: '靠墙或放在桌上,与腰同高' },
{ t: '退后 2 米', d: '让头和脚都进入画面' },
{ t: '光线充足', d: '避免背光,识别更准' },
{ t: '留出空间', d: '周围 1 米内无障碍物' },
]
const phaseLabel = ref('')
const levelText = computed(() => ['', '轻松', '适中', '较累'][props.exercise.level])
const minutes = computed(() => {
const spec = MOTIONS[props.exercise.id]
const sec = (props.target / spec.repsPerCycle) * spec.cycleSeconds
return Math.max(1, Math.round(sec / 60))
})
function change(dir: 1 | -1) {
const [lo, hi] = props.exercise.repRange
const next = Math.min(hi, Math.max(lo, props.target + dir * props.exercise.repStep))
if (next !== props.target) emit('update:target', next)
}
</script>
<style lang="scss" scoped>
.intro {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: #f4fbf4;
display: flex;
flex-direction: column;
}
.intro__scroll {
flex: 1;
height: 0;
}
.stage {
position: relative;
height: 800rpx;
background: linear-gradient(180deg, var(--accent-mid) 0%, var(--accent-soft) 60%, #f4fbf4 100%);
display: flex;
flex-direction: column;
align-items: center;
overflow: hidden;
}
.stage__glow {
position: absolute;
width: 620rpx;
height: 620rpx;
border-radius: 50%;
top: 120rpx;
background: radial-gradient(circle, rgba(255, 255, 255, 0.85) 0%, rgba(255, 255, 255, 0) 70%);
}
.stage__mascot {
position: relative;
width: 640rpx;
height: 640rpx;
margin-top: -40rpx;
}
.stage__phase {
position: relative;
margin-top: 8rpx;
display: flex;
align-items: center;
gap: 12rpx;
padding: 14rpx 32rpx;
border-radius: 999rpx;
background: #161d19;
color: #fff;
font-size: 28rpx;
font-weight: 600;
}
.stage__phase-dot {
width: 14rpx;
height: 14rpx;
border-radius: 50%;
background: var(--accent);
}
.stage__video {
position: absolute;
right: 32rpx;
bottom: 110rpx;
display: flex;
align-items: center;
gap: 10rpx;
padding: 12rpx 22rpx;
border-radius: 999rpx;
background: rgba(255, 255, 255, 0.9);
font-size: 24rpx;
color: #161d19;
box-shadow: 0 6rpx 20rpx rgba(22, 29, 25, 0.08);
}
.stage__play {
width: 0;
height: 0;
border-top: 10rpx solid transparent;
border-bottom: 10rpx solid transparent;
border-left: 16rpx solid var(--accent);
}
.sheet {
position: relative;
margin-top: -40rpx;
border-radius: 48rpx 48rpx 0 0;
background: #f4fbf4;
padding: 40rpx 40rpx 0;
}
.sheet__head {
display: flex;
align-items: center;
justify-content: space-between;
}
.sheet__name {
font-size: 48rpx;
font-weight: 800;
color: #161d19;
}
.level {
display: flex;
align-items: flex-end;
gap: 6rpx;
}
.level__bar {
width: 10rpx;
border-radius: 4rpx;
background: #dde4dd;
&:nth-child(1) {
height: 16rpx;
}
&:nth-child(2) {
height: 24rpx;
}
&:nth-child(3) {
height: 32rpx;
}
&.is-on {
background: var(--accent);
}
}
.level__text {
margin-left: 8rpx;
font-size: 24rpx;
color: #3c4a42;
}
.sheet__tagline {
display: block;
margin-top: 12rpx;
font-size: 28rpx;
color: #3c4a42;
}
.chips {
display: flex;
gap: 16rpx;
margin-top: 24rpx;
}
.chip {
padding: 8rpx 24rpx;
border-radius: 999rpx;
background: var(--accent-soft);
color: #161d19;
font-size: 24rpx;
font-weight: 600;
}
.block {
margin-top: 32rpx;
padding: 32rpx;
border-radius: 36rpx;
background: #fff;
box-shadow: 0 10rpx 32rpx rgba(22, 29, 25, 0.05);
}
.block__title {
display: block;
font-size: 30rpx;
font-weight: 700;
color: #161d19;
margin-bottom: 20rpx;
}
.point {
display: flex;
align-items: flex-start;
gap: 20rpx;
padding: 12rpx 0;
}
.point__no {
width: 40rpx;
height: 40rpx;
line-height: 40rpx;
border-radius: 50%;
background: var(--accent);
color: #fff;
font-size: 22rpx;
font-weight: 700;
text-align: center;
flex-shrink: 0;
}
.point__text {
font-size: 28rpx;
color: #161d19;
line-height: 40rpx;
}
.target {
display: flex;
align-items: center;
justify-content: space-between;
}
.target .block__title {
margin-bottom: 6rpx;
}
.target__hint {
font-size: 22rpx;
color: #6c7a71;
}
.stepper {
display: flex;
align-items: center;
gap: 20rpx;
}
.stepper__btn {
width: 68rpx;
height: 68rpx;
border-radius: 50%;
background: var(--accent-soft);
color: #161d19;
font-size: 40rpx;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
&.is-disabled {
opacity: 0.35;
}
}
.stepper__value {
min-width: 80rpx;
text-align: center;
font-size: 48rpx;
font-weight: 800;
color: #161d19;
}
.tips__grid {
display: flex;
flex-wrap: wrap;
gap: 16rpx;
}
.tips__item {
width: calc(50% - 8rpx);
box-sizing: border-box;
padding: 20rpx 22rpx;
border-radius: 24rpx;
background: #f4fbf4;
display: flex;
flex-direction: column;
}
.tips__t {
font-size: 26rpx;
font-weight: 700;
color: #161d19;
}
.tips__d {
margin-top: 6rpx;
font-size: 22rpx;
color: #6c7a71;
line-height: 1.4;
}
.intro__spacer {
height: 240rpx;
}
.cta {
position: absolute;
left: 0;
right: 0;
bottom: 0;
padding: 24rpx 40rpx calc(env(safe-area-inset-bottom) + 20rpx);
background: linear-gradient(180deg, rgba(244, 251, 244, 0) 0%, #f4fbf4 30%);
display: flex;
flex-direction: column;
align-items: center;
}
.cta__btn {
width: 100%;
height: 104rpx;
border-radius: 52rpx;
background: linear-gradient(135deg, #10b981 0%, #006c49 100%);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 16rpx 40rpx rgba(0, 108, 73, 0.3);
}
.cta__text {
color: #fff;
font-size: 34rpx;
font-weight: 800;
letter-spacing: 2rpx;
}
.cta__privacy {
margin-top: 14rpx;
font-size: 22rpx;
color: #6c7a71;
}
</style>
@@ -0,0 +1,410 @@
<template>
<view class="summary" :style="{ '--accent': exercise.color, '--accent-soft': soft }">
<scroll-view scroll-y class="summary__scroll">
<view class="summary__hero">
<view class="summary__mascot">
<CoachSprite mode="gesture" :gesture="summary.stars >= 2 ? 'cheer' : 'thumbs'" />
</view>
<text class="summary__title">{{ title }}</text>
<view class="summary__stars">
<text v-for="i in 3" :key="i" class="summary__star" :class="{ 'is-on': i <= summary.stars }">★</text>
</view>
<text class="summary__sub">{{ exercise.name }} · {{ summary.reps }}/{{ summary.targetReps }} 次</text>
</view>
<view class="summary__card summary__score">
<view class="score-ring" :style="{ background: ringBg }">
<view class="score-ring__inner">
<text class="score-ring__value">{{ summary.scores.length ? summary.avgScore : '--' }}</text>
<text class="score-ring__label">动作评分</text>
</view>
</view>
<view class="stats">
<view class="stats__item">
<text class="stats__value">{{ summary.reps }}</text>
<text class="stats__label">完成次数</text>
</view>
<view class="stats__item">
<text class="stats__value">{{ duration }}</text>
<text class="stats__label">训练用时</text>
</view>
<view class="stats__item">
<text class="stats__value">{{ summary.bestCombo }}</text>
<text class="stats__label">最高连击</text>
</view>
<view class="stats__item">
<text class="stats__value">{{ summary.kcal }}</text>
<text class="stats__label">约消耗千卡</text>
</view>
</view>
</view>
<view v-if="summary.scores.length" class="summary__card">
<view class="card-head">
<text class="card-head__title">每次动作质量</text>
<view class="legend">
<view class="legend__dot is-perfect" /><text class="legend__text">完美</text>
<view class="legend__dot is-good" /><text class="legend__text">不错</text>
<view class="legend__dot is-ok" /><text class="legend__text">待改进</text>
</view>
</view>
<view class="bars">
<view
v-for="(s, i) in summary.scores"
:key="i"
class="bars__bar"
:class="gradeClass(s)"
:style="{ height: barHeight(s) }"
/>
</view>
</view>
<view class="summary__card advice">
<view class="advice__icon"><text>{{ summary.adviceKind === 'tip' ? '练' : '赞' }}</text></view>
<view class="advice__body">
<text class="advice__title">{{ summary.adviceKind === 'tip' ? '下次注意' : '教练点评' }}</text>
<text class="advice__text">{{ summary.advice }}</text>
</view>
</view>
<text class="summary__note">千卡为估算值(按 60kg 体重),仅供参考</text>
<view class="summary__spacer" />
</scroll-view>
<view class="summary__actions-bg" />
<view class="summary__actions">
<view class="btn btn--ghost" @click="emit('change')"><text>换个动作</text></view>
<view class="btn btn--primary" @click="emit('restart')"><text>再练一组</text></view>
</view>
<view class="summary__done" @click="emit('done')"><text>完成</text></view>
</view>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import CoachSprite from './coach-sprite.vue'
import type { SessionSummary } from '../../pose/session'
import type { ExerciseInfo } from '../../pose/exercises'
const props = defineProps<{ summary: SessionSummary; exercise: ExerciseInfo }>()
const emit = defineEmits<{ (e: 'restart'): void; (e: 'change'): void; (e: 'done'): void }>()
const title = computed(() => {
if (props.summary.stars === 3) return '完美完成!'
if (props.summary.stars === 2) return '完成得不错!'
return props.summary.reps > 0 ? '今天也动起来了' : '下次再接再厉'
})
const duration = computed(() => {
const s = props.summary.durationSec
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`
})
const soft = computed(() => `${props.exercise.color}1f`)
const ringBg = computed(() => {
const deg = Math.round((props.summary.avgScore / 100) * 360)
return `conic-gradient(${props.exercise.color} ${deg}deg, #e6ece8 ${deg}deg)`
})
function gradeClass(s: number) {
return s >= 90 ? 'is-perfect' : s >= 75 ? 'is-good' : 'is-ok'
}
function barHeight(s: number) {
return `${Math.max(18, Math.round(((s - 50) / 50) * 120))}rpx`
}
</script>
<style lang="scss" scoped>
.summary {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 50;
background: linear-gradient(180deg, var(--accent-soft) 0%, #f4fbf4 38%, #f4fbf4 100%);
display: flex;
flex-direction: column;
}
.summary__scroll {
flex: 1;
height: 0;
}
.summary__hero {
display: flex;
flex-direction: column;
align-items: center;
padding-top: calc(env(safe-area-inset-top) + 60rpx);
}
.summary__mascot {
width: 420rpx;
height: 420rpx;
margin-bottom: -20rpx;
}
.summary__title {
margin-top: 8rpx;
font-size: 48rpx;
font-weight: 800;
color: #161d19;
}
.summary__stars {
display: flex;
gap: 12rpx;
margin-top: 12rpx;
}
.summary__star {
font-size: 52rpx;
color: #dde4dd;
&.is-on {
color: #fbbf24;
}
}
.summary__sub {
margin-top: 8rpx;
font-size: 26rpx;
color: #3c4a42;
}
.summary__card {
margin: 28rpx 32rpx 0;
padding: 32rpx;
background: #fff;
border-radius: 36rpx;
box-shadow: 0 12rpx 40rpx rgba(22, 29, 25, 0.06);
}
.summary__score {
display: flex;
align-items: center;
gap: 32rpx;
}
.score-ring {
width: 200rpx;
height: 200rpx;
border-radius: 50%;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
}
.score-ring__inner {
width: 164rpx;
height: 164rpx;
border-radius: 50%;
background: #fff;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.score-ring__value {
font-size: 60rpx;
font-weight: 800;
color: var(--accent);
line-height: 1.1;
}
.score-ring__label {
font-size: 22rpx;
color: #6c7a71;
}
.stats {
flex: 1;
display: flex;
flex-wrap: wrap;
row-gap: 24rpx;
}
.stats__item {
width: 50%;
display: flex;
flex-direction: column;
}
.stats__value {
font-size: 40rpx;
font-weight: 700;
color: #161d19;
}
.stats__label {
font-size: 22rpx;
color: #6c7a71;
margin-top: 4rpx;
}
.card-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 24rpx;
}
.card-head__title {
font-size: 30rpx;
font-weight: 700;
color: #161d19;
}
.legend {
display: flex;
align-items: center;
gap: 8rpx;
}
.legend__dot {
width: 14rpx;
height: 14rpx;
border-radius: 50%;
}
.legend__text {
font-size: 20rpx;
color: #6c7a71;
margin-right: 8rpx;
}
.bars {
display: flex;
align-items: flex-end;
gap: 8rpx;
height: 130rpx;
overflow: hidden;
}
.bars__bar {
flex: 1;
max-width: 36rpx;
border-radius: 10rpx 10rpx 4rpx 4rpx;
}
.is-perfect {
background: #10b981;
}
.is-good {
background: #6ee7b7;
}
.is-ok {
background: #fbbf24;
}
.advice {
display: flex;
align-items: center;
gap: 24rpx;
}
.advice__icon {
width: 72rpx;
height: 72rpx;
border-radius: 24rpx;
background: var(--accent-soft);
color: var(--accent);
font-size: 30rpx;
font-weight: 800;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.advice__body {
display: flex;
flex-direction: column;
}
.advice__title {
font-size: 24rpx;
color: #6c7a71;
}
.advice__text {
font-size: 30rpx;
font-weight: 600;
color: #161d19;
margin-top: 6rpx;
}
.summary__note {
display: block;
text-align: center;
font-size: 22rpx;
color: #93a39a;
margin-top: 28rpx;
}
.summary__spacer {
height: 260rpx;
}
/* 底部操作区:渐变底色把滚动内容衬到按钮下方,避免文字与按钮叠在一起 */
.summary__actions-bg {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: calc(env(safe-area-inset-bottom) + 260rpx);
background: linear-gradient(180deg, rgba(244, 251, 244, 0) 0%, #f4fbf4 36%);
pointer-events: none;
}
.summary__actions {
position: absolute;
left: 32rpx;
right: 32rpx;
bottom: calc(env(safe-area-inset-bottom) + 110rpx);
display: flex;
gap: 24rpx;
}
.btn {
height: 100rpx;
border-radius: 50rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 32rpx;
font-weight: 700;
}
.btn--ghost {
flex: 1;
background: #fff;
color: #161d19;
box-shadow: 0 8rpx 24rpx rgba(22, 29, 25, 0.08);
}
.btn--primary {
flex: 1.4;
background: linear-gradient(135deg, #10b981, #006c49);
color: #fff;
box-shadow: 0 12rpx 32rpx rgba(0, 108, 73, 0.3);
}
.summary__done {
position: absolute;
left: 0;
right: 0;
bottom: calc(env(safe-area-inset-bottom) + 30rpx);
text-align: center;
font-size: 28rpx;
color: #3c4a42;
padding: 16rpx;
}
</style>
@@ -0,0 +1,102 @@
<template>
<view class="ring" :class="'is-' + theme" :style="{ background: ringBg }">
<view class="ring__inner">
<text :key="'n' + count" class="ring__n">{{ count }}</text>
<text class="ring__target">/ {{ target }}</text>
</view>
</view>
</template>
<script setup lang="ts">
/**
* 计数环:大号次数 + 完成进度环。人站在两米外也要看得清,所以数字要大。
* 次数变化时数字弹一下。
*/
import { computed } from 'vue'
const props = withDefaults(
defineProps<{
count: number
target: number
color?: string
/** light:亮色舞台上;dark:摄像头画面上 */
theme?: 'light' | 'dark'
}>(),
{ color: '#34d399', theme: 'light' },
)
const ringBg = computed(() => {
const deg = Math.round(Math.min(1, props.count / Math.max(1, props.target)) * 360)
const track = props.theme === 'light' ? 'rgba(22,29,25,0.08)' : 'rgba(255,255,255,0.22)'
return `conic-gradient(${props.color} ${deg}deg, ${track} ${deg}deg)`
})
</script>
<style lang="scss" scoped>
.ring {
width: 196rpx;
height: 196rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 12rpx 36rpx rgba(22, 29, 25, 0.12);
}
.ring__inner {
width: 164rpx;
height: 164rpx;
border-radius: 50%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.is-light .ring__inner {
background: #fff;
}
.is-dark .ring__inner {
background: rgba(15, 23, 20, 0.72);
}
.ring__n {
font-size: 84rpx;
line-height: 1;
font-weight: 900;
font-variant-numeric: tabular-nums;
animation: ring-bump 0.35s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.is-light .ring__n {
color: #161d19;
}
.is-dark .ring__n {
color: #fff;
}
.ring__target {
margin-top: 4rpx;
font-size: 24rpx;
font-weight: 600;
}
.is-light .ring__target {
color: #6b7a72;
}
.is-dark .ring__target {
color: rgba(255, 255, 255, 0.75);
}
@keyframes ring-bump {
0% {
transform: scale(1.35);
}
100% {
transform: scale(1);
}
}
</style>
@@ -0,0 +1,199 @@
<template>
<view class="self" :class="['is-' + variant, 'is-' + mode]" :style="variant === 'pip' ? { top: pipTop + 'px' } : undefined" @click="emit('tap')">
<camera
v-if="mode === 'vision'"
class="self__cam"
device-position="front"
flash="off"
frame-size="medium"
resolution="medium"
@initdone="emit('ready')"
@error="emit('error')"
/>
<!-- 开发者工具没有摄像头:用一个灰色的“镜像小人”代表被识别的用户 -->
<view v-else class="self__sim">
<view class="self__sim-figure" :style="simStyle">
<CoachSprite :move="move" mode="mirror" :amount="amount" :side="side" :shadow="false" />
</view>
</view>
<view v-for="(m, i) in marks" :key="i" class="self__mark" :style="{ left: m.x + 'px', top: m.y + 'px' }" />
<view v-if="variant === 'pip'" class="self__badge">
<view class="self__dot" />
<text>{{ mode === 'vision' ? 'AI 识别中' : '模拟识别' }}</text>
</view>
</view>
</template>
<script setup lang="ts">
/**
* 用户自己的画面:训练中是右上角小窗,站位 / 离开画面时铺满全屏。
*
* 画面上不叠骨架、轮廓(主流 AI 跟练产品默认都不显示):站位靠底部的提示、语音和进度环引导,
* 需要纠正时只在对应关节闪一个红圈。切换小窗 / 全屏时 <camera> 不重建,识别不中断。
*/
import { computed } from 'vue'
import CoachSprite from './coach-sprite.vue'
import { toScreen } from '../../pose/viewport'
import type { Pose } from '../../pose/keypoints'
import type { MoveId, Side } from '../../pose/motions'
const props = withDefaults(
defineProps<{
mode: 'vision' | 'sim'
variant: 'pip' | 'full'
move: MoveId
/** 模拟模式下小人的动作幅度 */
amount?: number
side?: Side
pose?: Pose | null
frameWidth?: number
frameHeight?: number
/** 前置摄像头预览是镜像的,关节标记要跟着翻转 */
mirror?: boolean
/** 需要纠正的关节 */
warnJoints?: number[]
/** 本组件的显示尺寸(px),用来把关键点换算到屏幕坐标 */
width: number
height: number
/** 小窗距屏幕顶部的距离(px) */
pipTop?: number
}>(),
{ amount: 0, side: 'L', pose: null, frameWidth: 480, frameHeight: 640, mirror: true, warnJoints: () => [], pipTop: 0 },
)
const emit = defineEmits<{ (e: 'ready'): void; (e: 'error'): void; (e: 'tap'): void }>()
/** 全屏模拟画面:小人像站在两米外的人,居中、全身入镜(身高占画面 11%~88%,帧图里人物占 23%~95%) */
const simStyle = computed(() => {
if (props.variant !== 'full') return undefined
const box = (props.height * 0.76) / 0.72
return { width: box + 'px', height: box + 'px', left: (props.width - box) / 2 + 'px', top: props.height * 0.88 - box * 0.95 + 'px' }
})
const marks = computed(() => {
const pose = props.pose
if (props.mode !== 'vision' || !pose || !props.warnJoints.length) return []
const vp = { width: props.width, height: props.height, frameWidth: props.frameWidth, frameHeight: props.frameHeight, mirror: props.mirror }
return props.warnJoints.filter((j) => pose.points[j]).map((j) => toScreen(pose.points[j], vp))
})
</script>
<style lang="scss" scoped>
.self {
position: absolute;
overflow: hidden;
background: #0b1210;
}
.is-full {
top: 0;
left: 0;
z-index: 1;
width: 100%;
height: 100%;
}
.is-pip {
top: 0;
right: 24rpx;
z-index: 5;
width: 216rpx;
height: 288rpx;
border-radius: 28rpx;
border: 4rpx solid #fff;
box-shadow: 0 16rpx 40rpx rgba(22, 29, 25, 0.22);
}
.self__cam {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
.self__sim {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: radial-gradient(circle at 50% 38%, #2c3b35 0%, #121a17 75%);
}
.self__sim-figure {
position: absolute;
filter: grayscale(100%) brightness(1.05);
opacity: 0.9;
}
.is-pip .self__sim-figure {
top: 0;
left: 50%;
width: 288rpx;
height: 288rpx;
margin-left: -144rpx;
}
.self__mark {
position: absolute;
width: 56rpx;
height: 56rpx;
margin: -28rpx 0 0 -28rpx;
border-radius: 50%;
border: 6rpx solid #ef4444;
box-shadow: 0 0 20rpx rgba(239, 68, 68, 0.8);
animation: mark-pulse 0.8s ease-in-out infinite alternate;
}
.is-pip .self__mark {
width: 28rpx;
height: 28rpx;
margin: -14rpx 0 0 -14rpx;
border-width: 4rpx;
}
@keyframes mark-pulse {
0% {
transform: scale(0.85);
opacity: 0.7;
}
100% {
transform: scale(1.15);
opacity: 1;
}
}
.self__badge {
position: absolute;
left: 12rpx;
bottom: 12rpx;
display: flex;
align-items: center;
gap: 8rpx;
padding: 4rpx 14rpx;
border-radius: 999rpx;
background: rgba(15, 23, 20, 0.6);
text {
color: #fff;
font-size: 20rpx;
}
}
.self__dot {
width: 12rpx;
height: 12rpx;
border-radius: 50%;
background: #34d399;
animation: dot-blink 1.4s ease-in-out infinite;
}
@keyframes dot-blink {
50% {
opacity: 0.35;
}
}
</style>
@@ -0,0 +1,807 @@
<template>
<view class="page" :style="{ '--accent': ex.color }">
<!-- ===== 介绍 ===== -->
<PoseIntro
v-if="stage === 'intro'"
v-model:target="target"
:exercise="ex"
:nav-height="nav.height"
:video-url="videoUrl"
@start="enterCamera"
@video="showVideo = true"
/>
<view v-if="stage === 'intro'" class="intro-back" :style="{ top: nav.top + (nav.bar - 32) / 2 + 'px' }" @click="goBack">
<view class="intro-back__arrow" />
</view>
<!-- ===== 跟练 ===== -->
<view v-if="stage === 'camera'" class="cam-stage" :class="'is-' + theme">
<!-- 教练舞台:训练中的主画面(教练示范为主,自己的画面缩到右上角) -->
<view class="stage" :style="{ backgroundImage: stageBg }" />
<!--
教练只有一个实例:舞台上是大图,看自己时缩到右上角卡片。切换时不重建,图片只加载一次、动作不中断;
站位时教练已经在全屏画面后面加载好,倒计时出现时不会空白一下。
-->
<view
class="coach-box"
:class="'is-' + coachVariant"
:style="coachVariant === 'stage' ? coachBoxStyle : { top: pip.top + 'px' }"
@click="coachVariant === 'card' && swapView()"
>
<CoachSprite
:move="ex.id"
:mode="coachMode"
:amount="hud.liveAmount"
:side="hud.liveSide"
:paused="hud.phase !== 'training'"
:shadow="coachVariant === 'stage'"
@phase="coachLabel = $event"
@rep="onCoachRep"
/>
</view>
<!-- 自己的画面:训练中是右上角小窗;站位、离开画面或点小窗切换时铺满全屏 -->
<SelfView
v-if="mode !== 'guided'"
:mode="mode"
:variant="layout === 'self' ? 'full' : 'pip'"
:move="ex.id"
:amount="hud.liveAmount"
:side="hud.liveSide"
:pose="displayPose"
:frame-width="frame.width"
:frame-height="frame.height"
:mirror="mirrorOverlay"
:warn-joints="tip.joints"
:width="layout === 'self' ? win.width : pip.width"
:height="layout === 'self' ? win.height : pip.height"
:pip-top="pip.top"
@ready="onCameraReady"
@error="onCameraError"
@tap="swapView"
/>
<view v-if="layout === 'self'" class="shade-top" />
<view v-if="layout === 'self'" class="shade-bottom" />
<!-- 顶栏 -->
<view class="topbar" :style="{ paddingTop: nav.top + 'px', height: nav.bar + 'px' }">
<view class="icon-btn" @click="onClose"><text class="icon-btn__x">×</text></view>
<view class="topbar__center" @longpress="toggleMirror">
<text class="topbar__title">{{ ex.name }}</text>
<text class="topbar__time">{{ timeText }}</text>
</view>
<view class="topbar__actions">
<view v-if="bgm.available.value" class="icon-btn icon-btn--music" :class="{ 'is-off': !bgm.enabled.value }" @click="toggleBgm">
<text class="icon-btn__note">♪</text>
</view>
<view class="icon-btn" :class="{ 'is-off': !voice.enabled.value }" @click="voice.toggle()">
<text class="icon-btn__label">{{ voice.enabled.value ? '声' : '静' }}</text>
</view>
</view>
</view>
<!-- 真机排查用的识别数据面板:默认关闭,开发版编译时设置 VITE_POSE_DIAG=1 才显示 -->
<view v-if="poseDiagEnabled && mode === 'vision'" class="diag">
<text class="diag__text">{{ diagText }}</text>
</view>
<!-- 计数环 + 连击 -->
<view v-if="hud.phase !== 'framing'" class="ring-slot" :style="{ top: pip.top + 'px' }">
<RepRing :count="hud.count" :target="target" :color="ex.color" :theme="theme" />
<view v-if="hud.combo >= 3" :key="'combo' + hud.comboKey" class="combo">
<text class="combo__n">{{ hud.combo }}</text>
<text class="combo__t">连击</text>
</view>
</view>
<!-- 教练模式:跟练(按示范节奏)/ 镜像(模仿你的动作) -->
<view v-if="showModeSwitch" class="mode-switch" :style="{ top: pip.top + pip.height + 10 + 'px' }" @click="toggleCoachMode">
<text :class="{ 'is-on': coachMode === 'demo' }">跟练</text>
<text :class="{ 'is-on': coachMode === 'mirror' }">镜像</text>
</view>
<!-- 中央:倒计时 / 离开画面 -->
<view v-if="hud.phase === 'countdown'" class="scrim" />
<view class="center">
<view v-if="hud.phase === 'countdown'" class="countdown">
<text :key="'cd' + hud.countdown" class="countdown__n">{{ hud.countdown }}</text>
<text class="countdown__t">准备开始</text>
</view>
<view v-else-if="hud.phase === 'lost'" class="lost">
<text class="lost__title">已自动暂停</text>
<text class="lost__sub">回到画面中间就继续</text>
</view>
</view>
<!-- 评分弹出 -->
<view v-if="pop.key && hud.phase === 'training'" :key="'pop' + pop.key" class="pop" :class="'pop--' + pop.grade" :style="{ top: pip.top + 24 + 'px' }">
<text class="pop__text">{{ pop.text }}</text>
<text class="pop__score">+{{ pop.score }}</text>
</view>
<!-- 底部:站位 / 纠正提示 / 动作口令 + 幅度条 + 暂停 -->
<view class="dock">
<view v-if="hud.phase === 'framing'" class="framing">
<view class="framing__ring" :style="{ background: holdRingBg }">
<view class="framing__ring-inner"><text>{{ hud.framingIssue === 'none' ? '保持' : '站位' }}</text></view>
</view>
<text class="framing__tip">{{ framingTip }}</text>
</view>
<view v-else-if="tip.text" :key="'tip' + tip.key" class="tip">
<text class="tip__icon">!</text>
<text class="tip__text">{{ tip.text }}</text>
</view>
<view v-else-if="hud.phase === 'training' && coachLabel" class="caption">
<text>{{ coachLabel }}</text>
</view>
<view v-if="hud.phase !== 'framing'" class="dock__row">
<view v-if="mode !== 'guided'" class="dock__meter">
<AmountMeter :amount="hud.liveAmount" :goal="goal" :color="ex.color" :theme="theme" :hint="METER_HINTS[ex.id]" />
</view>
<text v-else class="dock__guided">领练模式 · 跟着小甄的节奏</text>
<view class="dock__pause" @click="onPause"><view class="pause-icon" /></view>
</view>
</view>
<!-- 暂停 -->
<view v-if="hud.phase === 'paused'" class="mask">
<view class="dialog">
<text class="dialog__title">已暂停</text>
<text class="dialog__sub">已完成 {{ hud.count }} 次,休息一下再继续</text>
<view class="dialog__btn is-primary" @click="onResume"><text>继续训练</text></view>
<view class="dialog__btn" @click="finish"><text>结束并查看结果</text></view>
</view>
</view>
</view>
<!-- ===== 结算 ===== -->
<PoseSummary v-if="stage === 'summary' && summary" :summary="summary" :exercise="ex" @restart="restart" @change="goHub" @done="goBack" />
<!-- 完整示范视频 -->
<view v-if="showVideo" class="video-mask">
<video class="video" :src="videoUrl" autoplay object-fit="contain" :show-fullscreen-btn="false" @error="onVideoError" />
<view class="video-close" :style="{ top: nav.top + 'px' }" @click="showVideo = false"><text>×</text></view>
</view>
</view>
</template>
<script setup lang="ts">
import { computed, reactive, ref, shallowRef, watch } from 'vue'
import { onLoad, onUnload, onHide, onShow } from '@dcloudio/uni-app'
import CoachSprite from './components/coach-sprite.vue'
import SelfView from './components/self-view.vue'
import RepRing from './components/rep-ring.vue'
import AmountMeter from './components/amount-meter.vue'
import PoseIntro from './components/pose-intro.vue'
import PoseSummary from './components/pose-summary.vue'
import { EXERCISES, bgmUrlOf, videoUrlOf } from '../pose/exercises'
import { FRAMING_TIPS, KP, isVisible, type FramingIssue, type Pose } from '../pose/keypoints'
import { MOTIONS, MISTAKE_POSES, type MoveId, type Side } from '../pose/motions'
import { TRACK_MIN_SCORE, tipTextsOf } from '../pose/counters'
import { TrainingSession, summarize, type SessionEvent, type SessionPhase, type SessionSummary } from '../pose/session'
import { usePoseCamera } from '../pose/usePoseCamera'
import { requestCameraPermission, type PermissionApi } from '../pose/cameraPermission'
import { useCoachVoice } from '../pose/useCoachVoice'
import { useBgm } from '../pose/useBgm'
import type { BgmState } from '../pose/bgm'
import { appendHistory, uniStorage } from '../pose/history'
import { poseDiagEnabled, remoteLogEnabled, rlog, rlogSession } from '../pose/remoteLog'
const IS_DEV = process.env.NODE_ENV === 'development'
const MIRROR_KEY = 'pose_coach_overlay_mirror'
/** 底部栏高度(rpx,不含安全区):口令 / 提示 + 幅度条 + 暂停 */
const DOCK_RPX = 250
const METER_HINTS: Record<MoveId, string> = {
squat: '再蹲低一点',
march: '膝盖再抬高',
jack: '手再举高',
raise: '抬到与肩平',
}
const move = ref<MoveId>('squat')
const ex = computed(() => EXERCISES[move.value])
const target = ref(12)
const stage = ref<'intro' | 'camera' | 'summary'>('intro')
/** vision:真实识别;sim:开发者工具模拟识别;guided:无识别领练 */
const mode = ref<'vision' | 'sim' | 'guided'>('vision')
const coachMode = ref<'demo' | 'mirror'>('demo')
/** 训练中是否把自己的画面放大为主画面(点小窗切换) */
const selfMain = ref(false)
/** 计一次所需的动作幅度,幅度条上的目标线 */
const goal = ref(0.5)
const coachLabel = ref('')
const showVideo = ref(false)
const summary = shallowRef<SessionSummary | null>(null)
const displayPose = shallowRef<Pose | null>(null)
const mirrorOverlay = ref(readMirror())
const hud = reactive({
phase: 'framing' as SessionPhase,
framingIssue: 'no_body' as FramingIssue,
holdProgress: 0,
countdown: 3,
count: 0,
combo: 0,
comboKey: 0,
activeMs: 0,
liveAmount: 0,
liveSide: 'L' as Side,
})
const pop = reactive({ key: 0, text: '', score: 0, grade: 'perfect' })
const tip = reactive({ key: 0, text: '', joints: [] as number[] })
const voice = useCoachVoice()
const bgm = useBgm(bgmUrlOf(IS_DEV))
const camera = usePoseCamera((p) => onPose(p))
const frame = camera.frame
let session: TrainingSession | null = null
let ticker: ReturnType<typeof setInterval> | null = null
let simTimer: ReturnType<typeof setInterval> | null = null
let tipTimer: ReturnType<typeof setTimeout> | null = null
let lastFramingVoice = 0
let lastFramingIssue: FramingIssue = 'no_body'
/** 训练中关键点看不清(算不出动作指标)从何时开始;持续一段时间就提示用户调整站位 */
let blindSince = 0
let lastBlindTip = 0
const BLIND_TIP_AFTER_MS = 1500
const BLIND_TIP_COOLDOWN_MS = 5000
const videoUrl = computed(() => videoUrlOf(ex.value, IS_DEV))
// ───────── 导航栏尺寸(自定义导航) ─────────
const nav = (() => {
const sys = uni.getSystemInfoSync()
const top = Number(sys.statusBarHeight) || 20
let bar = 44
try {
const m = uni.getMenuButtonBoundingClientRect()
if (m?.height) bar = (m.top - top) * 2 + m.height
} catch (e) {}
return { top, bar, height: top + bar }
})()
// ───────── 布局尺寸 ─────────
const win = (() => {
const s = uni.getSystemInfoSync()
const safeBottom = s.safeArea ? Math.max(0, s.screenHeight - s.safeArea.bottom) : 0
return { width: s.windowWidth, height: s.windowHeight, safeBottom }
})()
const rpx = (v: number) => (v * win.width) / 750
/** 右上角小窗(3:4,与摄像头帧比例一致,画面不裁切) */
const pip = { width: rpx(216), height: rpx(288), top: nav.height + rpx(16) }
/** 帧图里人物最高点占画框高度的比例(开合跳双手举过头顶最高),脚底在 95% 处 */
const FIGURE_TOP: Record<MoveId, number> = { squat: 0.23, march: 0.23, raise: 0.22, jack: 0.14 }
/** 教练舞台上的教练:尽量大,脚踩在底部栏上方,头顶(或举起的手)不碰计数环、小窗和模式切换 */
const coachBoxStyle = computed(() => {
const feet = win.height - win.safeBottom - rpx(DOCK_RPX) - rpx(12)
const ceiling = pip.top + pip.height + rpx(76)
const size = Math.min(win.width * 1.5, (feet - ceiling) / (0.95 - FIGURE_TOP[ex.value.id]))
return { width: size + 'px', height: size + 'px', left: (win.width - size) / 2 + 'px', top: feet - size * 0.95 + 'px' }
})
// ───────── 派生展示 ─────────
/** coach:教练为主画面、自己在小窗;self:自己的画面铺满(站位、离开画面、手动切换) */
const layout = computed<'coach' | 'self'>(() => {
if (mode.value === 'guided') return 'coach'
if (hud.phase === 'framing' || hud.phase === 'lost') return 'self'
return selfMain.value ? 'self' : 'coach'
})
/** 教练舞台是亮色,摄像头画面上用深色半透明面板 */
const theme = computed(() => (layout.value === 'self' ? 'dark' : 'light'))
/** 教练在舞台上(站位、离开画面时藏在全屏画面后面),还是缩成右上角卡片(训练中看自己时) */
const coachVariant = computed(() => (layout.value === 'self' && hud.phase !== 'framing' && hud.phase !== 'lost' ? 'card' : 'stage'))
const showModeSwitch = computed(() => mode.value !== 'guided' && ['countdown', 'training', 'paused'].includes(hud.phase))
/** 亮色舞台:中心提亮 + 自上而下淡淡的主题色(底色在样式里,保证不透出深色页面) */
const stageBg = computed(
() => `radial-gradient(circle at 50% 45%, #ffffff 0%, rgba(255,255,255,0) 62%), linear-gradient(180deg, ${ex.value.color}0d 0%, ${ex.value.color}29 100%)`,
)
const framingTip = computed(() => (hud.framingIssue === 'none' ? '很好,保持不动…' : FRAMING_TIPS[hud.framingIssue]))
const holdRingBg = computed(() => {
const deg = Math.round(hud.holdProgress * 360)
return `conic-gradient(#34d399 ${deg}deg, rgba(255,255,255,0.25) ${deg}deg)`
})
/** 诊断面板:识别引擎状态 → 原始数据 → 站位 / 阶段 / 幅度 / 次数,逐层看是哪一步断了 */
const diagText = computed(() => {
const d = camera.diag.value
const e = d.events
return [
`识别 ${camera.status.value} ${camera.fps.value}次/秒 帧${frame.value.width}×${frame.value.height} 延迟${d.latencyMs}ms`,
`事件 add${e.add} upd${e.update} rm${e.remove} 超时${e.timeout} 错${e.error}`,
d.raw || '尚未识别到人',
`看清(≥${TRACK_MIN_SCORE}) 髋${seen(KP.L_HIP, KP.R_HIP)} 膝${seen(KP.L_KNEE, KP.R_KNEE)} 踝${seen(KP.L_ANKLE, KP.R_ANKLE)} 腕${seen(KP.L_WRIST, KP.R_WRIST)} 指标 ${metricText()}`,
`站位 ${hud.framingIssue} 阶段 ${hud.phase} 幅度 ${hud.liveAmount.toFixed(2)}/${goal.value.toFixed(2)} 次数 ${hud.count}`,
].join('\n')
})
/** 一对左右关键点里看清了几个(按计数用的阈值) */
function seen(...idx: number[]) {
const p = displayPose.value
return p ? idx.filter((i) => isVisible(p, i, TRACK_MIN_SCORE)).length : 0
}
/** 计数器最近一帧的原始指标;null 表示关键点看不清,这一帧被跳过 */
function metricText() {
const m = session?.counter.metric
if (m === undefined) return '-'
return m === null ? '看不清(整帧跳过)' : m.toFixed(2)
}
const timeText = computed(() => {
const s = Math.floor(hud.activeMs / 1000)
return `${String(Math.floor(s / 60)).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}`
})
// ───────── 背景音乐 ─────────
/** 倒计时开始淡入;站位、暂停时淡出暂停;离开跟练(结算、回到介绍)后停止,下次从头播 */
const bgmState = computed<BgmState>(() => {
if (stage.value !== 'camera') return 'stopped'
return hud.phase === 'countdown' || hud.phase === 'training' || hud.phase === 'lost' ? 'playing' : 'paused'
})
/** 教练说口令、纠错时压低音乐(报数、倒计时的数字短而密,不压,免得音乐一抽一抽的);离开画面时也压低 */
const bgmDuck = computed(() => {
const s = voice.speaking.value
if (s && s !== 'count' && hud.phase !== 'countdown') return 0.35
return hud.phase === 'lost' ? 0.5 : 1
})
watch(bgmState, (s) => bgm.setState(s))
watch(bgmDuck, (r) => bgm.setDuck(r))
function toggleBgm() {
bgm.toggle()
uni.showToast({ title: bgm.enabled.value ? '背景音乐已开启' : '背景音乐已关闭', icon: 'none' })
}
// ───────── 生命周期 ─────────
onLoad((q: any) => {
const m = (q?.move || 'squat') as MoveId
move.value = EXERCISES[m] ? m : 'squat'
target.value = ex.value.targetReps
uni.setNavigationBarTitle({ title: ex.value.name })
followMuteSwitch(false)
// 进入页面即预合成常用语音
voice.preload(voicePhrases())
})
let preloadTimer: ReturnType<typeof setTimeout> | null = null
watch(target, () => {
if (preloadTimer) clearTimeout(preloadTimer)
preloadTimer = setTimeout(() => voice.preload(voicePhrases()), 600)
})
onHide(() => {
// 后台定时器可能不跑:音乐立即停,不等淡出
bgm.hush()
if (stage.value === 'camera' && session && hud.phase !== 'paused' && hud.phase !== 'done') onPause()
})
onShow(() => bgm.setState(bgmState.value))
onUnload(() => {
teardown()
followMuteSwitch(true)
})
/**
* 手机开着静音键也要听得到口令、报数和音乐(iOS 默认跟着静音键走);和别的 App 的声音混播,不打断用户自己放的歌。
* 这是整个小程序的全局设置,离开页面时恢复默认,不影响其它页面。
*/
function followMuteSwitch(follow: boolean) {
// #ifdef MP-WEIXIN
try {
;(wx as any).setInnerAudioOption?.({ obeyMuteSwitch: follow, mixWithOther: true })
} catch (e) {}
// #endif
}
function readMirror() {
try {
const v = uni.getStorageSync(MIRROR_KEY)
return v === '' ? true : Boolean(v)
} catch (e) {
return true
}
}
/** 长按标题切换关节标记的镜像方向(不同机型前置摄像头帧方向可能不同,现场可校正) */
function toggleMirror() {
mirrorOverlay.value = !mirrorOverlay.value
try {
uni.setStorageSync(MIRROR_KEY, mirrorOverlay.value)
} catch (e) {}
uni.showToast({ title: mirrorOverlay.value ? '画面标记:镜像' : '画面标记:原始', icon: 'none' })
}
/** 点小窗:教练和自己的画面互换大小(站位、离开画面时固定看自己) */
function swapView() {
if (mode.value === 'guided' || hud.phase === 'framing' || hud.phase === 'lost') return
selfMain.value = !selfMain.value
}
// ───────── 进入跟练 ─────────
function enterCamera() {
// #ifdef MP-WEIXIN
requestCamera()
// #endif
// #ifndef MP-WEIXIN
startStage('guided')
// #endif
}
/** 申请摄像头权限:真机用 VisionKit;开发者工具(开发环境)用合成姿态模拟识别,不打开电脑摄像头 */
async function requestCamera() {
const inDevtools = (uni.getSystemInfoSync() as any).platform === 'devtools'
if (inDevtools && IS_DEV) {
startStage('sim')
return
}
const { result, errMsg } = await requestCameraPermission(uni as unknown as PermissionApi)
if (result === 'granted') startStage('vision')
else if (result === 'denied') askOpenSetting()
else if (result === 'privacy_refused') askPrivacyAgain()
else cameraUnavailable(errMsg)
}
/** 用户拒绝过摄像头:设置页里有这一项,引导去开启;开启后回来直接开始 */
function askOpenSetting() {
uni.showModal({
title: '需要摄像头权限',
content: 'AI 需要通过前置摄像头识别动作,画面只在手机本地处理、不会上传。也可以不开摄像头,跟着小甄练。',
confirmText: '去开启',
cancelText: '直接跟练',
success: (r) => {
if (!r.confirm) return startStage('guided')
uni.openSetting({
success: (res) => {
if (res.authSetting?.['scope.camera']) startStage('vision')
},
})
},
})
}
/** 用户没同意《隐私保护指引》:重新弹出官方隐私授权,同意后再申请摄像头 */
function askPrivacyAgain() {
// #ifdef MP-WEIXIN
const w: any = wx
if (typeof w.requirePrivacyAuthorize === 'function') {
w.requirePrivacyAuthorize({
success: () => requestCamera(),
fail: () => startStage('guided'),
})
return
}
// #endif
cameraUnavailable('当前微信版本不支持隐私授权')
}
/**
* 小程序后台《用户隐私保护指引》没有声明“摄像头”(errno 112)等:用户侧无法开启,改用领练模式。
* 开发版直接说明原因,方便排查。
*/
function cameraUnavailable(errMsg: string) {
uni.showModal({
title: '暂时无法使用摄像头',
content: IS_DEV
? `小程序后台的《用户隐私保护指引》还没有声明“摄像头”,补充后才能开启动作识别。\n${errMsg}`
: '动作识别暂时不可用,先跟着小甄一起练吧。',
showCancel: false,
confirmText: '跟着小甄练',
success: () => startStage('guided'),
})
}
function startStage(m: 'vision' | 'sim' | 'guided') {
stopTimers()
mode.value = m
selfMain.value = false
session = new TrainingSession(ex.value.id, target.value, { detectLost: m !== 'guided' })
rlogSession()
rlog('stage', { mode: m, move: ex.value.id, target: target.value })
goal.value = session.counter.goal
Object.assign(hud, { phase: 'framing', framingIssue: 'no_body', holdProgress: 0, count: 0, combo: 0, activeMs: 0, liveAmount: 0 })
pop.key = 0
clearTip()
stage.value = 'camera'
uni.setKeepScreenOn({ keepScreenOn: true })
voice.preload(voicePhrases())
bgm.prepare()
ticker = setInterval(() => handle(session?.tick(Date.now()) ?? []), 200)
if (m === 'guided') startGuided()
if (m === 'sim') startSim()
if (m === 'vision') voice.speak('请站到画面中间', 'high')
}
function onCameraReady() {
rlog('camera_ready', { mode: mode.value })
if (mode.value !== 'vision') return
camera.start()
}
// VisionKit 启动、取帧失败是异步回调,可能晚于任何固定等待时间;随时失败随时切到领练,不会卡在站位。
// 同步触发:再来一次时状态会在同一轮里 unsupported → starting → unsupported,默认异步合并后看不到变化
watch(
() => camera.status.value,
(s) => {
if (stage.value !== 'camera' || mode.value !== 'vision') return
if (s === 'unsupported' || s === 'error') fallbackToGuided('当前手机暂不支持动作识别,已切换为领练模式')
},
{ flush: 'sync' },
)
function onCameraError() {
rlog('camera_error')
camera.onCameraError(null)
fallbackToGuided('摄像头不可用,已切换为领练模式')
}
function fallbackToGuided(msg: string) {
if (mode.value === 'guided') return
rlog('fallback_guided', { msg, status: camera.status.value, err: camera.errorText.value })
camera.stop()
stopSim()
uni.showToast({ title: msg, icon: 'none', duration: 2500 })
startStage('guided')
}
// ───────── 姿态 → 会话 ─────────
function onPose(p: Pose | null) {
displayPose.value = p
if (!session) return
handle(session.feed(p, Date.now()))
checkBlind()
if (remoteLogEnabled) logPose(p)
}
/** 训练中持续看不清(离太近、腿出画面等)时不会计数:提示用户怎么站,而不是让人干练不计数 */
function checkBlind() {
if (!session || hud.phase !== 'training' || session.counter.metric !== null) {
blindSince = 0
return
}
const now = Date.now()
if (!blindSince) blindSince = now
if (now - blindSince < BLIND_TIP_AFTER_MS || now - lastBlindTip < BLIND_TIP_COOLDOWN_MS) return
lastBlindTip = now
const text = FRAMING_TIPS[hud.framingIssue === 'none' ? 'partial' : hud.framingIssue]
showTip(text, [])
voice.speak(text, 'high')
rlog('blind_tip', { issue: hud.framingIssue })
}
/** 远程日志:这一帧算到哪一步了(站位 → 阶段 → 指标 → 幅度 → 次数),以及关键点看清了几个 */
function logPose(p: Pose | null) {
const m = session?.counter.metric
const key = [KP.L_HIP, KP.R_HIP, KP.L_KNEE, KP.R_KNEE, KP.L_ANKLE, KP.R_ANKLE]
const y = (a: number, b: number) => (p ? +(((p.points[a]?.y ?? 0) + (p.points[b]?.y ?? 0)) / 2).toFixed(3) : null)
rlog('pose', {
ph: hud.phase,
iss: hud.framingIssue,
m: m === null || m === undefined ? null : +m.toFixed(3),
amt: +hud.liveAmount.toFixed(2),
cnt: hud.count,
vis: `h${seen(KP.L_HIP, KP.R_HIP)}k${seen(KP.L_KNEE, KP.R_KNEE)}a${seen(KP.L_ANKLE, KP.R_ANKLE)}w${seen(KP.L_WRIST, KP.R_WRIST)}`,
sc: p ? +Math.min(...key.map((i) => p.scores[i] ?? 0)).toFixed(2) : null,
y: p ? { nose: +(p.points[KP.NOSE]?.y ?? 0).toFixed(3), hip: y(KP.L_HIP, KP.R_HIP), knee: y(KP.L_KNEE, KP.R_KNEE), ankle: y(KP.L_ANKLE, KP.R_ANKLE) } : null,
})
}
function handle(events: SessionEvent[]) {
if (!session) return
for (const e of events) onEvent(e)
hud.phase = session.phase
hud.framingIssue = session.framingIssue
hud.holdProgress = session.holdProgress
hud.countdown = session.countdown
hud.count = session.count
hud.combo = session.combo
hud.activeMs = session.activeMs
const live = session.live
hud.liveAmount = live.amount
// 教练面对用户,像照镜子:用户抬左腿,教练抬右腿
hud.liveSide = live.side === 'L' ? 'R' : 'L'
if (hud.phase === 'framing' && hud.framingIssue !== lastFramingIssue) {
lastFramingIssue = hud.framingIssue
const now = Date.now()
if (hud.framingIssue !== 'none' && now - lastFramingVoice > 4000) {
lastFramingVoice = now
voice.speak(FRAMING_TIPS[hud.framingIssue], 'high')
}
}
}
function onEvent(e: SessionEvent) {
rlog('ev', { ev: e })
switch (e.type) {
case 'phase':
if (e.phase === 'countdown') voice.speak('准备', 'high')
if (e.phase === 'training') {
voice.speak('开始', 'high')
}
if (e.phase === 'lost') voice.speak('请回到画面中', 'high')
// 达到目标次数通常发生在识别结果回调里:推迟到回调返回后再结算、关摄像头、切页面,避免在原生回调里拆掉自己
if (e.phase === 'done') setTimeout(finish, 0)
break
case 'countdown':
voice.speak(String(e.n), 'high')
break
case 'rep': {
if (mode.value === 'guided') {
// 领练模式不评分:只报数
if (shouldSpeakCount(e.count)) voice.speak(String(e.count), 'count')
break
}
const text = e.grade === 'perfect' ? '完美' : e.grade === 'good' ? '不错' : '加油'
Object.assign(pop, { key: pop.key + 1, text, score: e.score, grade: e.grade })
if (e.tip) {
showTip(e.tip, e.joints ?? [])
voice.speak(e.tip, 'high')
} else if (shouldSpeakCount(e.count)) {
voice.speak(String(e.count), 'count')
}
break
}
case 'tip':
showTip(e.text, e.joints ?? [])
voice.speak(e.text, 'high')
break
case 'combo':
hud.comboKey += 1
voice.speak(`连续${e.combo}次,漂亮`)
break
}
}
function shouldSpeakCount(n: number) {
return target.value <= 30 || n % 5 === 0
}
/** 训练中要说的话:进入页面、调整目标次数、开始训练时预合成,训练中即时出声 */
function voicePhrases() {
const counts: string[] = []
for (let i = 1; i <= target.value; i++) if (shouldSpeakCount(i)) counts.push(String(i))
const cues = ['准备', '开始', '3', '2', '1', '太棒了,完成训练', '请回到画面中', '请站到画面中间', ...Object.values(FRAMING_TIPS)]
return [...cues, ...counts, ...tipTextsOf(move.value)]
}
function showTip(text: string, joints: number[]) {
Object.assign(tip, { key: tip.key + 1, text, joints })
if (tipTimer) clearTimeout(tipTimer)
tipTimer = setTimeout(clearTip, 2400)
}
function clearTip() {
Object.assign(tip, { text: '', joints: [] })
}
// ───────── 领练模式(无识别) ─────────
function startGuided() {
if (!session) return
handle(session.startCountdown(Date.now()))
}
/** 领练模式:教练每做完一次计一次(报数和画面同步;暂停时教练停下,也不会“补数”) */
function onCoachRep() {
if (mode.value !== 'guided' || !session || session.phase !== 'training') return
handle(session.addGuidedRep())
}
// ───────── 开发者工具模拟识别 ─────────
function startSim() {
const spec = MOTIONS[ex.value.id]
frame.value = { width: 480, height: 640 }
const aspect = 0.75
let phase = 0
let last = Date.now()
simTimer = setInterval(() => {
// 站位、倒计时时站着不动;训练开始后和教练同时起步、同样节奏(暂停时一起停),计数才和画面对得上
const now = Date.now()
if (hud.phase === 'training') phase += (now - last) / 1000 / spec.cycleSeconds
last = now
// 每第 4 个周期演示一次错误动作
const cycle = Math.floor(phase)
const pts = cycle % 4 === 3 ? MISTAKE_POSES[ex.value.id](phase) : spec.poseAt(phase)
const jitter = () => (Math.random() - 0.5) * 0.004
// 模拟人站在约 2 米外:人物占画面高度约 65%,居中偏下
const points = pts.map((p) => ({ x: 0.5 + ((p.x - 0.5) * 0.7) / aspect + jitter(), y: 0.3 + (p.y - 0.2) * 0.85 + jitter() }))
onPose({ points, scores: points.map(() => 0.9), t: Date.now(), aspect })
}, 100)
}
function stopSim() {
if (simTimer) clearInterval(simTimer)
simTimer = null
}
function stopTimers() {
stopSim()
if (ticker) clearInterval(ticker)
ticker = null
}
// ───────── 暂停 / 结束 ─────────
function onPause() {
if (!session) return
rlog('pause', { phase: hud.phase })
handle(session.pause())
}
function onResume() {
if (!session) return
handle(session.resume(Date.now()))
// 倒计时中暂停,恢复后会回到站位;领练模式没有识别、站位永远过不去,直接重新倒计时
if (mode.value === 'guided' && session.phase === 'framing') startGuided()
}
function finish() {
if (!session || stage.value !== 'camera') return
rlog('finish', { count: hud.count, phase: hud.phase })
// 只改状态、不派发 done 事件:done 的处理就是调用 finish,派发会重入,同一次训练记两条历史
session.finish()
const s = summarize(session, ex.value.netMet)
if (mode.value === 'guided') {
Object.assign(s, {
stars: s.reps >= target.value ? 2 : 1,
advice: '开启摄像头识别,可获得动作评分和实时纠正',
adviceKind: 'tip',
})
}
if (s.reps >= target.value) voice.speak('太棒了,完成训练', 'high')
appendHistory(uniStorage, s)
summary.value = s
teardown()
stage.value = 'summary'
}
function teardown() {
camera.stop()
stopTimers()
if (tipTimer) clearTimeout(tipTimer)
if (preloadTimer) clearTimeout(preloadTimer)
uni.setKeepScreenOn({ keepScreenOn: false })
}
function onClose() {
if (!session || hud.count === 0) {
teardown()
stage.value = 'intro'
return
}
onPause()
}
function toggleCoachMode() {
coachMode.value = coachMode.value === 'demo' ? 'mirror' : 'demo'
rlog('coach_mode', { mode: coachMode.value })
uni.showToast({ title: coachMode.value === 'demo' ? '跟练:跟着小甄的节奏' : '镜像:小甄模仿你的动作', icon: 'none' })
}
function restart() {
summary.value = null
enterCamera()
}
function goHub() {
const pages = getCurrentPages()
const prev = pages[pages.length - 2] as any
if (prev?.route === 'training/pages/pose-hub') uni.navigateBack()
else uni.redirectTo({ url: '/training/pages/pose-hub' })
}
function goBack() {
if (getCurrentPages().length > 1) uni.navigateBack()
else uni.redirectTo({ url: '/training/pages/pose-hub' })
}
function onVideoError() {
showVideo.value = false
uni.showToast({ title: '示范视频暂时无法播放', icon: 'none' })
}
</script>
<style lang="scss" scoped>
@import './components/pose-coach.scss';
</style>
+355
View File
@@ -0,0 +1,355 @@
<template>
<view class="hub">
<view class="hero">
<view class="hero__text">
<text class="hero__eyebrow">AI 跟练</text>
<text class="hero__title">和小甄一起{{ '\n' }}动起来</text>
<text class="hero__sub">摄像头识别动作 · 自动计数 · 实时纠正</text>
<view class="hero__stats">
<view class="hero__stat">
<text class="hero__stat-n">{{ stats.todayReps }}</text>
<text class="hero__stat-t">今日次数</text>
</view>
<view class="hero__divider" />
<view class="hero__stat">
<text class="hero__stat-n">{{ stats.todayMinutes }}</text>
<text class="hero__stat-t">今日分钟</text>
</view>
<view class="hero__divider" />
<view class="hero__stat">
<text class="hero__stat-n">{{ stats.streak }}</text>
<text class="hero__stat-t">连续天数</text>
</view>
</view>
</view>
<view class="hero__mascot">
<CoachSprite mode="gesture" gesture="wave" :shadow="false" />
</view>
</view>
<view class="section">
<text class="section__title">选择动作</text>
<text class="section__sub">建议饭后 30 分钟开始,每次 10~15 分钟</text>
</view>
<view class="grid">
<view v-for="ex in list" :key="ex.id" class="card" :style="{ '--accent': ex.color }" @click="open(ex.id)">
<view class="card__cover" :style="{ background: ex.color + '1f' }">
<image class="card__img" :src="coverOf(ex.id)" :style="COVER_CROP[ex.id]" mode="aspectFit" />
<view class="card__level">
<text>{{ ['', '轻松', '适中', '较累'][ex.level] }}</text>
</view>
</view>
<view class="card__body">
<text class="card__name">{{ ex.name }}</text>
<text class="card__tagline">{{ ex.tagline }}</text>
<view class="card__meta">
<text class="card__reps">{{ ex.targetReps }} 次</text>
<view class="card__go"><text>开练</text></view>
</view>
</view>
</view>
</view>
<view class="note">
<view class="note__icon"><text>护</text></view>
<view class="note__body">
<text class="note__title">隐私与安全</text>
<text class="note__text">动作识别在手机本地完成,画面不会上传。运动中如有胸闷、头晕、心慌、出冷汗,请立即停止休息;使用胰岛素或降糖药的用户,运动前血糖低于 5.6 mmol/L 请先少量加餐。</text>
</view>
</view>
<view class="hub__spacer" />
</view>
</template>
<script setup lang="ts">
import { reactive } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import CoachSprite from './components/coach-sprite.vue'
import { EXERCISES, EXERCISE_ORDER } from '../pose/exercises'
import { frameUrl, peakFrame } from '../pose/coachFrames'
import { loadHistory, statsOf, uniStorage } from '../pose/history'
import type { MoveId } from '../pose/motions'
const list = EXERCISE_ORDER.map((id) => EXERCISES[id])
const stats = reactive({ todayReps: 0, todayMinutes: 0, streak: 0 })
onShow(() => {
Object.assign(stats, statsOf(loadHistory(uniStorage)))
})
/**
* 封面裁切:帧图是统一画布(人物站在下方、头顶留出举手空间),
* 按各动作人物实际占位放大,让人物撑满卡片。
*/
const COVER_CROP: Record<MoveId, Record<string, string>> = {
squat: { width: '530rpx', height: '530rpx', left: '-100rpx', top: '-222rpx' },
march: { width: '380rpx', height: '380rpx', left: '-31rpx', top: '-66rpx' },
jack: { width: '345rpx', height: '345rpx', left: '-20rpx', top: '-35rpx' },
raise: { width: '386rpx', height: '386rpx', left: '-28rpx', top: '-74rpx' },
}
function coverOf(id: MoveId) {
return frameUrl(peakFrame(id))
}
function open(id: MoveId) {
uni.navigateTo({ url: `/training/pages/pose-coach?move=${id}` })
}
</script>
<style lang="scss" scoped>
.hub {
min-height: 100vh;
background: #f4fbf4;
padding: 24rpx 32rpx 0;
box-sizing: border-box;
}
.hero {
position: relative;
display: flex;
padding: 40rpx 36rpx;
border-radius: 48rpx;
background: linear-gradient(135deg, #10b981 0%, #006c49 100%);
box-shadow: 0 20rpx 48rpx rgba(0, 108, 73, 0.25);
overflow: hidden;
}
.hero::after {
content: '';
position: absolute;
right: -120rpx;
top: -120rpx;
width: 420rpx;
height: 420rpx;
border-radius: 50%;
background: rgba(255, 255, 255, 0.12);
}
.hero__text {
position: relative;
z-index: 1;
flex: 1;
display: flex;
flex-direction: column;
}
.hero__eyebrow {
align-self: flex-start;
padding: 4rpx 18rpx;
border-radius: 999rpx;
background: rgba(255, 255, 255, 0.2);
color: #fff;
font-size: 22rpx;
font-weight: 700;
letter-spacing: 2rpx;
}
.hero__title {
margin-top: 16rpx;
color: #fff;
font-size: 48rpx;
font-weight: 800;
line-height: 1.25;
white-space: pre-line;
}
.hero__sub {
margin-top: 12rpx;
color: rgba(255, 255, 255, 0.85);
font-size: 22rpx;
}
.hero__stats {
display: flex;
align-items: center;
margin-top: 32rpx;
padding: 18rpx 20rpx;
border-radius: 24rpx;
background: rgba(0, 0, 0, 0.14);
width: 420rpx;
}
.hero__stat {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
}
.hero__stat-n {
color: #fff;
font-size: 36rpx;
font-weight: 800;
}
.hero__stat-t {
color: rgba(255, 255, 255, 0.75);
font-size: 20rpx;
}
.hero__divider {
width: 1rpx;
height: 48rpx;
background: rgba(255, 255, 255, 0.25);
}
/* canvas 不受父元素 overflow 裁剪,教练必须完整放在卡片内 */
.hero__mascot {
position: absolute;
right: -24rpx;
bottom: 0;
width: 340rpx;
height: 420rpx;
z-index: 1;
}
.section {
margin: 44rpx 4rpx 20rpx;
display: flex;
flex-direction: column;
}
.section__title {
font-size: 36rpx;
font-weight: 800;
color: #161d19;
}
.section__sub {
margin-top: 6rpx;
font-size: 24rpx;
color: #6c7a71;
}
// 列间距用 space-between 分配剩余空间:rpx 换算 px 有取整,“两张卡 + 固定 gap”恰好凑满 100% 时
// 部分机型会多出 1px,第二张卡被挤到下一行
.grid {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
row-gap: 24rpx;
}
.card {
width: calc(50% - 12rpx);
border-radius: 36rpx;
background: #fff;
overflow: hidden;
box-shadow: 0 10rpx 32rpx rgba(22, 29, 25, 0.06);
}
.card__cover {
position: relative;
height: 300rpx;
overflow: hidden;
}
.card__img {
position: absolute;
}
.card__level {
position: absolute;
left: 16rpx;
top: 16rpx;
padding: 4rpx 16rpx;
border-radius: 999rpx;
background: rgba(255, 255, 255, 0.9);
font-size: 20rpx;
font-weight: 700;
color: #161d19;
}
.card__body {
padding: 20rpx 22rpx 24rpx;
display: flex;
flex-direction: column;
}
.card__name {
font-size: 30rpx;
font-weight: 800;
color: #161d19;
}
.card__tagline {
margin-top: 6rpx;
font-size: 22rpx;
color: #6c7a71;
line-height: 1.4;
height: 62rpx;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.card__meta {
margin-top: 14rpx;
display: flex;
align-items: center;
justify-content: space-between;
}
.card__reps {
font-size: 22rpx;
color: #3c4a42;
font-weight: 600;
}
.card__go {
padding: 6rpx 22rpx;
border-radius: 999rpx;
background: var(--accent);
color: #fff;
font-size: 22rpx;
font-weight: 700;
}
.note {
margin-top: 32rpx;
display: flex;
gap: 20rpx;
padding: 28rpx;
border-radius: 32rpx;
background: #e8f0e9;
}
.note__icon {
width: 56rpx;
height: 56rpx;
border-radius: 18rpx;
background: #fff;
color: #006c49;
font-size: 26rpx;
font-weight: 800;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.note__body {
display: flex;
flex-direction: column;
}
.note__title {
font-size: 26rpx;
font-weight: 700;
color: #161d19;
}
.note__text {
margin-top: 6rpx;
font-size: 22rpx;
color: #3c4a42;
line-height: 1.6;
}
.hub__spacer {
height: calc(env(safe-area-inset-bottom) + 40rpx);
}
</style>
@@ -0,0 +1,215 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { createBgm, type BgmAudio } from '../bgm.ts'
const URL = 'https://cdn.example.com/music/calm.mp3'
function fakeAudio() {
const calls: string[] = []
let onError: ((err: { errMsg?: string; errCode?: number }) => void) | null = null
const audio: BgmAudio = {
src: '',
loop: false,
volume: 1,
play: () => calls.push('play'),
pause: () => calls.push('pause'),
stop: () => calls.push('stop'),
destroy: () => calls.push('destroy'),
onError: (cb) => {
onError = cb
},
}
return { audio, calls, fail: () => onError?.({ errMsg: 'play:fail', errCode: 10002 }) }
}
/** 手动推进的定时器:run() 执行到渐变结束,返回执行了几次 */
function fakeClock() {
let fn: (() => void) | null = null
return {
every: (_ms: number, f: () => void) => {
fn = f
return () => {
fn = null
}
},
tick() {
fn?.()
},
run(limit = 1000) {
let n = 0
while (fn && n < limit) {
fn()
n += 1
}
return n
},
get running() {
return fn !== null
},
}
}
function setup(url = URL) {
const clock = fakeClock()
const player = fakeAudio()
let created = 0
const reasons: string[] = []
const bgm = createBgm({
url,
volume: 0.4,
every: clock.every,
create: () => {
created += 1
return player.audio
},
onUnavailable: (r) => reasons.push(r),
})
return { bgm, clock, player, reasons, created: () => created }
}
test('开始播放:循环、从静音淡入到设定音量', () => {
const { bgm, clock, player } = setup()
bgm.setState('playing')
assert.equal(player.audio.src, URL)
assert.equal(player.audio.loop, true)
assert.deepEqual(player.calls, ['play'])
assert.equal(player.audio.volume, 0)
clock.tick()
assert.ok(player.audio.volume > 0 && player.audio.volume < 0.4, '第一步只升一点')
const ticks = clock.run()
assert.equal(player.audio.volume, 0.4)
assert.ok(ticks >= 20, `淡入要持续一段时间,实际 ${ticks} 步`)
})
test('教练说话时压低、说完恢复;压得快、恢复得慢', () => {
const { bgm, clock, player } = setup()
bgm.setState('playing')
clock.run()
bgm.setDuck(0.35)
const down = clock.run()
assert.ok(Math.abs(player.audio.volume - 0.14) < 1e-9)
bgm.setDuck(1)
const up = clock.run()
assert.equal(player.audio.volume, 0.4)
assert.ok(down <= 6, `压低要快,实际 ${down} 步`)
assert.ok(up > down, '恢复比压低慢')
assert.deepEqual(player.calls, ['play'], '压低不会暂停播放器')
})
test('暂停:先淡出再暂停;继续:接着播', () => {
const { bgm, clock, player } = setup()
bgm.setState('playing')
clock.run()
bgm.setState('paused')
clock.tick()
assert.deepEqual(player.calls, ['play'], '淡出过程中还在播')
clock.run()
assert.equal(player.audio.volume, 0)
assert.deepEqual(player.calls, ['play', 'pause'])
bgm.setState('playing')
clock.run()
assert.deepEqual(player.calls, ['play', 'pause', 'play'])
assert.equal(player.audio.volume, 0.4)
})
test('停止:淡出后回到开头;暂停状态下停止也会回到开头', () => {
const a = setup()
a.bgm.setState('playing')
a.clock.run()
a.bgm.setState('stopped')
a.clock.run()
assert.deepEqual(a.player.calls, ['play', 'stop'])
const b = setup()
b.bgm.setState('playing')
b.clock.run()
b.bgm.setState('paused')
b.clock.run()
b.bgm.setState('stopped')
assert.deepEqual(b.player.calls, ['play', 'pause', 'stop'])
})
test('中途改主意:淡出一半又继续播,不会先暂停再播', () => {
const { bgm, clock, player } = setup()
bgm.setState('playing')
clock.run()
bgm.setState('paused')
clock.tick()
clock.tick()
bgm.setState('playing')
clock.run()
assert.deepEqual(player.calls, ['play'])
assert.equal(player.audio.volume, 0.4)
})
test('关掉背景音乐:不出声;训练中重新打开,接着播', () => {
const { bgm, clock, player, created } = setup()
bgm.setEnabled(false)
bgm.prepare()
bgm.setState('playing')
clock.run()
assert.equal(created(), 0, '关着的时候不去加载音乐')
bgm.setEnabled(true)
clock.run()
assert.deepEqual(player.calls, ['play'])
assert.equal(player.audio.volume, 0.4)
bgm.setEnabled(false)
clock.run()
assert.deepEqual(player.calls, ['play', 'pause'])
})
test('提前准备:建好播放器开始缓冲,但不出声', () => {
const { bgm, player, created } = setup()
bgm.prepare()
bgm.prepare()
assert.equal(created(), 1)
assert.equal(player.audio.src, URL)
assert.deepEqual(player.calls, [])
})
test('加载失败:静默放弃、只通知一次,之后不再尝试', () => {
const { bgm, clock, player, reasons, created } = setup()
bgm.setState('playing')
player.fail()
player.fail()
assert.equal(bgm.available, false)
assert.equal(reasons.length, 1)
assert.ok(player.calls.includes('destroy'))
assert.equal(clock.running, false)
bgm.setState('paused')
bgm.setState('playing')
clock.run()
assert.equal(created(), 1, '不会重新创建播放器')
})
test('没有配置音乐地址:不可用,也不创建播放器', () => {
const { bgm, clock, created } = setup('')
assert.equal(bgm.available, false)
bgm.prepare()
bgm.setState('playing')
clock.run()
assert.equal(created(), 0)
})
test('切到后台:立即静音暂停(后台定时器可能不跑,不能靠淡出)', () => {
const { bgm, clock, player } = setup()
bgm.setState('playing')
clock.run()
bgm.hush()
assert.equal(player.audio.volume, 0)
assert.deepEqual(player.calls, ['play', 'pause'])
assert.equal(clock.running, false)
// 回到前台、训练继续时照常淡入
bgm.setState('playing')
clock.run()
assert.deepEqual(player.calls, ['play', 'pause', 'play'])
})
test('销毁:释放播放器、停止定时器', () => {
const { bgm, clock, player } = setup()
bgm.setState('playing')
clock.tick()
bgm.destroy()
assert.ok(player.calls.includes('destroy'))
assert.equal(clock.running, false)
})
@@ -0,0 +1,56 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { classifyAuthError, requestCameraPermission, type PermissionApi } from '../cameraPermission.ts'
function fakeApi(setting: Record<string, boolean> | null, authorizeError?: { errno?: number; errMsg: string }) {
const calls = { authorize: 0 }
const api: PermissionApi = {
getSetting({ success, fail }) {
if (setting) success?.({ authSetting: setting })
else fail?.({ errMsg: 'getSetting:fail' })
},
authorize({ success, fail }) {
calls.authorize += 1
if (authorizeError) fail?.(authorizeError)
else success?.()
},
}
return { api, calls }
}
test('失败原因分类:后台未声明 / 未同意隐私指引 / 用户拒绝', () => {
assert.equal(classifyAuthError({ errno: 112, errMsg: 'authorize:fail api scope is not declared in the privacy agreement' }), 'undeclared')
assert.equal(classifyAuthError({ errMsg: 'authorize:fail api scope is not declared in the privacy agreement' }), 'undeclared')
assert.equal(classifyAuthError({ errno: 104, errMsg: 'authorize:fail privacy permission is not authorized' }), 'privacy_refused')
assert.equal(classifyAuthError({ errMsg: 'authorize:fail auth deny' }), 'denied')
assert.equal(classifyAuthError({ errMsg: 'authorize:fail system error' }), 'error')
assert.equal(classifyAuthError(undefined), 'error')
})
test('设置里已授权:直接可用,不再弹授权框', async () => {
const { api, calls } = fakeApi({ 'scope.camera': true })
assert.equal((await requestCameraPermission(api)).result, 'granted')
assert.equal(calls.authorize, 0)
})
test('设置里拒绝过:引导去设置页,不再弹授权框', async () => {
const { api, calls } = fakeApi({ 'scope.camera': false })
assert.equal((await requestCameraPermission(api)).result, 'denied')
assert.equal(calls.authorize, 0)
})
test('从没问过:弹授权框,按结果返回', async () => {
assert.equal((await requestCameraPermission(fakeApi({}).api)).result, 'granted')
const undeclared = await requestCameraPermission(
fakeApi({}, { errno: 112, errMsg: 'authorize:fail api scope is not declared in the privacy agreement' }).api,
)
assert.equal(undeclared.result, 'undeclared')
assert.match(undeclared.errMsg, /privacy agreement/)
assert.equal((await requestCameraPermission(fakeApi({}, { errMsg: 'authorize:fail auth deny' }).api)).result, 'denied')
})
test('读取设置失败时仍然尝试授权', async () => {
const { api, calls } = fakeApi(null)
assert.equal((await requestCameraPermission(api)).result, 'granted')
assert.equal(calls.authorize, 1)
})
@@ -0,0 +1,108 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { crispBlend, framePair, framesToLoad, frameUrl, hdCropOf, hdFrameUrl, peakFrame, sequenceOf } from '../coachFrames.ts'
import { COACH_SEQUENCES } from '../coachSequences.ts'
import { MOTIONS, createRepEdge, type MoveId } from '../motions.ts'
const MOVES: MoveId[] = ['squat', 'march', 'jack', 'raise']
/** 按播放规则(换帧点之前显示前一帧)取当前显示的帧在序列中的位置 */
function shownIndex(m: MoveId, amount: number, side: 'L' | 'R') {
const p = framePair(m, amount, side)
return sequenceOf(m, side).indexOf(crispBlend(p.w) < 0.5 ? p.a : p.b)
}
test('每个序列至少 9 帧,幅度从 0 严格递增到 1', () => {
for (const [name, seq] of Object.entries(COACH_SEQUENCES)) {
assert.ok(seq.frames.length >= 9, name)
assert.equal(seq.amounts.length, seq.frames.length, name)
assert.equal(seq.amounts[0], 0, name)
assert.equal(seq.amounts[seq.amounts.length - 1], 1, name)
for (let i = 1; i < seq.amounts.length; i++) assert.ok(seq.amounts[i] > seq.amounts[i - 1], `${name} 第 ${i} 帧`)
}
})
test('左右交替的动作共用起始站姿,换边时不会跳', () => {
for (const m of ['march', 'jack'] as MoveId[]) assert.equal(sequenceOf(m, 'L')[0], sequenceOf(m, 'R')[0])
})
test('幅度 0 停在第一帧,幅度 1 停在最后一帧', () => {
for (const m of MOVES) {
for (const side of ['L', 'R'] as const) {
const frames = sequenceOf(m, side)
const p0 = framePair(m, 0, side)
assert.equal(p0.a, frames[0])
assert.ok(crispBlend(p0.w) < 1e-6)
const p1 = framePair(m, 1, side)
assert.equal(p1.b, frames[frames.length - 1])
assert.ok(Math.abs(crispBlend(p1.w) - 1) < 1e-6)
}
}
})
test('幅度连续变化时逐帧前进:不回退、不跳帧', () => {
for (const m of MOVES) {
for (const side of ['L', 'R'] as const) {
let prev = 0
for (let i = 0; i <= 1000; i++) {
const idx = shownIndex(m, i / 1000, side)
assert.ok(idx >= prev, `${m}/${side} 回退`)
assert.ok(idx - prev <= 1, `${m}/${side} 跳帧`)
prev = idx
}
assert.equal(prev, sequenceOf(m, side).length - 1)
}
}
})
test('示范节奏一个周期会经过该动作的全部帧', () => {
for (const m of MOVES) {
const seen = new Set<string>()
for (let i = 0; i < 2000; i++) {
const a = MOTIONS[m].amountAt(i / 2000)
const p = framePair(m, a.amount, a.side)
seen.add(crispBlend(p.w) < 0.5 ? p.a : p.b)
}
assert.deepEqual([...seen].sort(), framesToLoad(m).sort(), m)
}
})
test('大部分时间显示单张清晰帧,只在换帧附近过渡', () => {
let crisp = 0
for (let i = 0; i <= 100; i++) {
const w = crispBlend(i / 100)
if (w < 0.01 || w > 0.99) crisp += 1
}
assert.ok(crisp >= 85, `清晰帧占比 ${crisp}%`)
})
test('帧地址指向分包内置 PNG', () => {
assert.equal(frameUrl('sq_00'), '/training/static/coach/sq_00.png')
assert.ok(peakFrame('squat').startsWith('sq_'))
})
test('领练计数:教练每个动作周期恰好完成 repsPerCycle 次,且在回到起始附近时计数', () => {
for (const m of MOVES) {
const spec = MOTIONS[m]
const edge = createRepEdge()
let reps = 0
for (let i = 0; i <= 3000; i++) {
const a = spec.amountAt((i / 1000) * 1)
if (edge(a.amount)) {
reps += 1
assert.ok(a.amount <= 0.25, `${m} 计数时幅度 ${a.amount}`)
}
}
assert.equal(reps, spec.repsPerCycle * 3, m)
}
})
test('高清帧:地址带版本号,裁切框在画布内且包含该动作的人物区域', () => {
assert.match(hdFrameUrl('https://cdn.example.com/coach', 'sq_00'), /^https:\/\/cdn\.example\.com\/coach\/sq_00\.webp\?v=\w+$/)
for (const m of MOVES) {
const [x0, y0, x1, y1] = hdCropOf(m)
assert.ok(0 <= x0 && x0 < x1 && x1 <= 1 && 0 <= y0 && y0 < y1 && y1 <= 1, m)
// 人物站在画布中下部:裁切框至少覆盖头顶(约 23%)到脚底(95%)
assert.ok(y0 <= 0.23 && y1 >= 0.95, `${m} ${y0}~${y1}`)
}
})
@@ -0,0 +1,278 @@
/**
* 计数器单测:用示范动作(motions.ts)合成姿态序列来验证。
* 运行:node --test training/pose/__tests__/
*/
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { createCounter, type CoachEvent } from '../counters.ts'
import { MISTAKE_POSES, MOTIONS, type MoveId } from '../motions.ts'
import { sampleMotion } from './sampleMotion.ts'
import type { Pose } from '../keypoints.ts'
const MOVES: MoveId[] = ['squat', 'march', 'jack', 'raise']
/** 固定种子的伪随机,模拟关键点抖动 */
function rng(seed: number) {
let s = seed
return () => ((s = (s * 16807) % 2147483647) / 2147483647) - 0.5
}
function run(move: MoveId, frames: Pose[]) {
const c = createCounter(move)
const events: CoachEvent[] = []
for (const f of frames) events.push(...c.update(f))
return { count: c.count, events, reps: events.filter((e) => e.type === 'rep'), tips: events.map((e) => (e.type === 'tip' ? e.text : e.tip)).filter(Boolean) }
}
function mistakeFrames(move: MoveId, seconds: number, fps = 12): Pose[] {
const spec = MOTIONS[move]
return Array.from({ length: Math.round(seconds * fps) }, (_, i) => {
const points = MISTAKE_POSES[move](i / fps / spec.cycleSeconds)
return { points, scores: points.map(() => 0.9), t: (i / fps) * 1000 }
})
}
/** 同一姿态放进 3:4 竖屏画面:x 归一化坐标被拉伸,aspect 修正后几何不变 */
function toPortrait(frames: Pose[], aspect = 0.75): Pose[] {
return frames.map((f) => ({ ...f, aspect, points: f.points.map((p) => ({ x: 0.5 + (p.x - 0.5) / aspect, y: p.y })) }))
}
for (const move of MOVES) {
const cycles = 5
const expected = cycles * MOTIONS[move].repsPerCycle
const frames = sampleMotion(move, MOTIONS[move].cycleSeconds * cycles + 0.3)
test(`${move}: 标准示范动作计数准确且评分高`, () => {
const r = run(move, frames)
assert.equal(r.count, expected)
for (const rep of r.reps) if (rep.type === 'rep') assert.ok(rep.score >= 85, `score ${rep.score}`)
assert.deepEqual(r.tips, [])
})
test(`${move}: 关键点抖动下计数仍准确`, () => {
const noise = rng(7)
const noisy = frames.map((f) => ({ ...f, points: f.points.map((p) => ({ x: p.x + noise() * 0.008, y: p.y + noise() * 0.008 })) }))
assert.equal(run(move, noisy).count, expected)
})
test(`${move}: 竖屏画面比例下结果一致`, () => {
assert.equal(run(move, toPortrait(frames)).count, expected)
})
test(`${move}: 低帧率(6fps)下不漏计`, () => {
assert.equal(run(move, sampleMotion(move, MOTIONS[move].cycleSeconds * cycles + 0.3, 6)).count, expected)
})
}
test('squat: 膝盖内扣会被提示', () => {
const r = run('squat', mistakeFrames('squat', MOTIONS.squat.cycleSeconds * 3 + 0.3))
assert.equal(r.count, 3)
assert.ok(r.tips.some((t) => t!.includes('膝盖向外')), JSON.stringify(r.tips))
})
test('march: 抬腿太低不计数并提示', () => {
const r = run('march', mistakeFrames('march', MOTIONS.march.cycleSeconds * 4))
assert.equal(r.count, 0)
assert.ok(r.tips.some((t) => t!.includes('抬高')), JSON.stringify(r.tips))
})
test('jack: 手臂没举过头不计数并提示', () => {
const r = run('jack', mistakeFrames('jack', MOTIONS.jack.cycleSeconds * 3))
assert.equal(r.count, 0)
assert.ok(r.tips.some((t) => t!.includes('举过头顶')), JSON.stringify(r.tips))
})
test('raise: 耸肩或抬太高会被提示', () => {
const r = run('raise', mistakeFrames('raise', MOTIONS.raise.cycleSeconds * 3 + 0.3))
assert.equal(r.count, 3)
assert.ok(r.tips.some((t) => t!.includes('耸肩') || t!.includes('与肩同高')), JSON.stringify(r.tips))
})
test('关键点不可见时不计数', () => {
const frames = sampleMotion('squat', 10).map((f) => ({ ...f, scores: f.scores.map(() => 0.1) }))
assert.equal(run('squat', frames).count, 0)
})
test('同一提示在冷却时间内不重复', () => {
const r = run('march', mistakeFrames('march', 3))
const n = r.tips.filter((t) => t!.includes('抬高')).length
assert.ok(n <= 1, `tips ${n}`)
})
test('live 幅度跟随动作:到位时接近 1,站立时接近 0', () => {
for (const move of ['squat', 'march', 'jack', 'raise'] as MoveId[]) {
const c = createCounter(move)
let max = 0
let min = 1
for (const f of sampleMotion(move, MOTIONS[move].cycleSeconds * 2)) {
c.update(f)
max = Math.max(max, c.live.amount)
min = Math.min(min, c.live.amount)
}
assert.ok(max > 0.85, `${move} max ${max}`)
assert.ok(min < 0.12, `${move} min ${min}`)
}
})
test('live 左右侧判断正确(高抬腿)', () => {
const c = createCounter('march')
const spec = MOTIONS.march
const left = spec.poseAt(0.25)
c.update({ points: left, scores: left.map(() => 0.9), t: 0 })
assert.equal(c.live.side, 'L')
const right = spec.poseAt(0.75)
c.update({ points: right, scores: right.map(() => 0.9), t: 100 })
assert.equal(c.live.side, 'R')
})
test('幅度条目标线:到达目标幅度才计数', () => {
for (const m of ['squat', 'march', 'jack', 'raise'] as const) {
const c = createCounter(m)
assert.ok(c.goal > 0.2 && c.goal < 0.9, `${m} goal=${c.goal}`)
}
})
/** 把示范动作的幅度按比例缩小(与站立姿势线性插值),模拟做得没那么到位的用户 */
function shallow(move: MoveId, k: number, seconds: number, fps = 12): Pose[] {
const stand = MOTIONS[move].poseAt(0)
return sampleMotion(move, seconds, fps).map((f) => ({
...f,
points: f.points.map((p, i) => ({ x: stand[i].x + (p.x - stand[i].x) * k, y: stand[i].y + (p.y - stand[i].y) * k })),
}))
}
test('squat: 左右只看清一侧的髋和踝也能计数', () => {
const cycles = 4
const frames = sampleMotion('squat', MOTIONS.squat.cycleSeconds * cycles + 0.3).map((f) => ({
...f,
scores: f.scores.map((s, i) => ([12, 16].includes(i) ? 0.05 : s)),
}))
assert.equal(run('squat', frames).count, cycles)
})
test('动作要求放宽:幅度没到旧标准、但到了新标准的动作也计数', () => {
// 各动作选一个幅度:峰值落在旧进入阈值和新进入阈值之间(放宽前不计、放宽后计数)
const cases: Array<[MoveId, number, number]> = [
['squat', 0.45, 0.18],
['march', 0.3, 0.22],
['jack', 0.52, 120],
['raise', 0.55, 68],
]
for (const [m, k, oldEnter] of cases) {
const cycles = 4
const c = createCounter(m)
let peak = 0
for (const f of shallow(m, k, MOTIONS[m].cycleSeconds * cycles + 0.3)) {
c.update(f)
if (c.metric !== null) peak = Math.max(peak, c.metric)
}
assert.ok(peak < oldEnter, `${m} peak ${peak} 应低于旧阈值 ${oldEnter}`)
assert.equal(c.count, cycles * MOTIONS[m].repsPerCycle, `${m} count ${c.count}`)
}
})
test('squat: 蹲到底时脚踝偶尔一帧没识别到,不会多计(真机复现)', () => {
const cycles = 4
const frames = sampleMotion('squat', MOTIONS.squat.cycleSeconds * cycles + 0.3)
const c = createCounter('squat')
frames.forEach((f, i) => {
// 蹲下过程中(幅度大于 0.2)每隔几帧丢一次脚踝
const deep = (c.metric ?? 0) > 0.2
const drop = deep && i % 4 === 0
c.update({ ...f, scores: f.scores.map((s, k) => (drop && (k === 15 || k === 16) ? 0.05 : s)) })
})
assert.equal(c.count, cycles)
})
test('单帧识别抖动回到站立位置不计数:需要连续两帧', () => {
const cycles = 3
const frames = sampleMotion('squat', MOTIONS.squat.cycleSeconds * cycles + 0.3)
const c = createCounter('squat')
const stand = frames[0]
frames.forEach((f, i) => {
c.update(f)
// 蹲到底时插入一帧“站立”噪声
if ((c.metric ?? 0) > 0.3 && i % 3 === 0) c.update({ ...stand, t: f.t + 1 })
})
assert.equal(c.count, cycles)
})
test('离得太近、腿部关键点置信度很低时不计数(真机复现:脚踝位置乱飘、人没有下蹲却被计数)', () => {
const stand = sampleMotion('squat', 0.1)[0]
const c = createCounter('squat')
for (let i = 0; i < 60; i++) {
const ankleSeen = i % 3 !== 0
const drift = 0.15 * Math.abs(Math.sin(i / 4)) // 脚踝的识别位置在乱飘
c.update({
...stand,
t: i * 100,
points: stand.points.map((p, k) => (k === 15 || k === 16 ? { x: p.x, y: p.y - drift } : p)),
scores: stand.scores.map((_, k) => ([11, 12, 13, 14].includes(k) ? 0.15 : k === 15 || k === 16 ? (ankleSeen ? 0.22 : 0.05) : 0.6)),
})
}
assert.equal(c.count, 0)
})
test('jack: 真机跟练的抬手幅度(手高过肩约 100°)计数,只举到与肩平不计', () => {
const c = createCounter('jack')
const sweep = (peak: number, t0: number) => {
// 手臂外展角 20° → peak → 20°,用示范动作在对应角度附近的姿态合成
const frames = sampleMotion('jack', MOTIONS.jack.cycleSeconds)
let best = frames[0]
let bestDiff = Infinity
for (const f of frames) {
c.update({ ...f, t: t0 })
const d = Math.abs((c.metric ?? 0) - peak)
if (d < bestDiff) [best, bestDiff] = [f, d]
}
return best
}
// 先找到 ~100° 和 ~88° 的姿态,再用它们做重复动作
const c100 = sweep(100, 0)
const c88 = sweep(88, 0)
const rest = sampleMotion('jack', 0.1)[0]
const counter = createCounter('jack')
let t = 0
for (let i = 0; i < 3; i++) for (const f of [rest, rest, c100, c100, rest, rest]) counter.update({ ...f, t: (t += 150) })
assert.equal(counter.count, 3, '约 100° 应计数')
const before = counter.count
for (let i = 0; i < 3; i++) for (const f of [rest, rest, c88, c88, rest, rest]) counter.update({ ...f, t: (t += 150) })
assert.equal(counter.count, before, '约 88° 不应计数')
})
test('单帧识别跳动越过计数线不计数(真机复现:高抬腿一帧跳到 0.46 又落回)', () => {
for (const m of ['squat', 'march', 'jack', 'raise'] as MoveId[]) {
const frames = sampleMotion(m, MOTIONS[m].cycleSeconds * 2)
const rest = frames[0]
// 找一帧“到位”的姿态当作跳动
const probe = createCounter(m)
let spike = rest
let best = -Infinity
for (const f of frames) {
probe.update(f)
if ((probe.metric ?? -Infinity) > best) [best, spike] = [probe.metric ?? -Infinity, f]
}
const c = createCounter(m)
let t = 0
for (let i = 0; i < 5; i++) for (const f of [rest, rest, rest, spike, rest, rest, rest]) c.update({ ...f, t: (t += 100) })
assert.equal(c.count, 0, `${m} 单帧跳动被计了 ${c.count} 次`)
}
})
test('jack: 不迈步只提示、不压分,提示 20 秒内最多一次', () => {
const cycles = 6
const stand = MOTIONS.jack.poseAt(0)
// 脚始终保持站立时的位置(不迈步),手臂照常
const frames = sampleMotion('jack', MOTIONS.jack.cycleSeconds * cycles + 0.3).map((f) => ({
...f,
points: f.points.map((p, i) => ([15, 16, 19, 20, 21, 22].includes(i) ? stand[i] : p)),
}))
const r = run('jack', frames)
assert.equal(r.count, cycles * MOTIONS.jack.repsPerCycle)
for (const rep of r.reps) if (rep.type === 'rep') assert.ok(rep.score >= 90, `score ${rep.score}`)
const stepTips = r.tips.filter((t) => t!.includes('迈一步')).length
const seconds = MOTIONS.jack.cycleSeconds * cycles
assert.ok(stepTips >= 1 && stepTips <= Math.ceil(seconds / 20), `step tips ${stepTips}`)
})
@@ -0,0 +1,55 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { appendHistory, loadHistory, statsOf, dayOf, type Storage } from '../history.ts'
import type { SessionSummary } from '../session.ts'
const memory = (): Storage => {
const m = new Map<string, unknown>()
return { get: (k) => (m.has(k) ? m.get(k) : ''), set: (k, v) => m.set(k, v) }
}
const summary = (reps: number): SessionSummary => ({
move: 'squat',
reps,
targetReps: 12,
avgScore: 90,
bestCombo: 5,
durationSec: 120,
kcal: 8,
stars: 3,
scores: [],
advice: '',
adviceKind: 'praise',
})
const DAY = 86400000
const now = new Date(2026, 8, 28, 20, 0, 0).getTime()
test('空存储返回空数组', () => {
assert.deepEqual(loadHistory(memory()), [])
})
test('0 次的训练不记录', () => {
const s = memory()
appendHistory(s, summary(0), now)
assert.equal(loadHistory(s).length, 0)
})
test('今日统计与连续天数', () => {
const s = memory()
appendHistory(s, summary(10), now - 2 * DAY)
appendHistory(s, summary(10), now - DAY)
appendHistory(s, summary(12), now)
appendHistory(s, summary(8), now)
const st = statsOf(loadHistory(s), now)
assert.equal(st.todayReps, 20)
assert.equal(st.todayMinutes, 4)
assert.equal(st.streak, 3)
})
test('今天还没练时,连续天数从昨天算起', () => {
const s = memory()
appendHistory(s, summary(10), now - DAY)
assert.equal(statsOf(loadHistory(s), now).streak, 1)
assert.equal(dayOf(now), '2026-09-28')
})
@@ -0,0 +1,60 @@
/**
* 关键点转换与平滑:真机上画面外的点坐标是 NaN,不能让它污染后续帧。
* 运行:node --test training/pose/__tests__/
*/
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { anchorToPose, checkFraming, smoothPose, KP, type Pose } from '../keypoints.ts'
import { createCounter } from '../counters.ts'
import { MOTIONS } from '../motions.ts'
import { sampleMotion } from './sampleMotion.ts'
const LEG = [KP.L_KNEE, KP.R_KNEE, KP.L_ANKLE, KP.R_ANKLE]
/** 示范姿态转成 VisionKit 的 anchor,指定的点坐标为 NaN(画面外) */
function anchorOf(pose: Pose, nanIdx: number[] = []) {
return {
points: pose.points.map((p, i) => (nanIdx.includes(i) ? { x: NaN, y: NaN } : { x: p.x, y: p.y })),
confidence: pose.scores.map((s, i) => (nanIdx.includes(i) ? 0.9 : s)),
score: 0.9,
}
}
test('anchorToPose:坐标为 NaN 的点视为看不见', () => {
const [f] = sampleMotion('squat', 0.1)
const p = anchorToPose(anchorOf(f, LEG), 480, 853, 0)!
for (const i of LEG) {
assert.equal(p.scores[i], 0, `score ${i}`)
assert.ok(Number.isFinite(p.points[i].x) && Number.isFinite(p.points[i].y), `point ${i}`)
}
})
test('smoothPose:上一帧是 NaN 时直接采用新坐标,不会一直是 NaN', () => {
const [f] = sampleMotion('squat', 0.1)
const poisoned: Pose = { ...f, points: f.points.map((p, i) => (LEG.includes(i) ? { x: NaN, y: NaN } : p)) }
const next = smoothPose(poisoned, f)
for (const i of LEG) assert.ok(Number.isFinite(next.points[i].y), `point ${i}`)
})
test('站位:脚踝坐标是 NaN 时不能判定为站位良好', () => {
const [f] = sampleMotion('squat', 0.1)
const p: Pose = { ...f, points: f.points.map((q, i) => (i === KP.L_ANKLE || i === KP.R_ANKLE ? { x: NaN, y: NaN } : q)) }
assert.notEqual(checkFraming(p), 'none')
})
test('真机复现:开始时腿在画面外(NaN),退回画面后深蹲照常计数', () => {
const cycles = 4
const frames = sampleMotion('squat', MOTIONS.squat.cycleSeconds * cycles + 0.3)
const counter = createCounter('squat')
let smoothed: Pose | null = null
// 前 1 秒:离得太近,膝和踝在画面外
for (let i = 0; i < 12; i++) {
const raw = anchorToPose(anchorOf(frames[0], LEG), 480, 853, i * 83)!
smoothed = smoothPose(smoothed, raw)
}
for (const f of frames) {
smoothed = smoothPose(smoothed, anchorToPose(anchorOf(f), 480, 853, f.t)!)
counter.update(smoothed)
}
assert.equal(counter.count, cycles)
})
@@ -0,0 +1,15 @@
/** 单测辅助:把示范动作按时间采样成 Pose 序列(文件名不带 .test,不会被当成用例执行) */
import type { Pose } from '../keypoints.ts'
import { MOTIONS, type MoveId } from '../motions.ts'
export function sampleMotion(id: MoveId, seconds: number, fps = 12, startMs = 0): Pose[] {
const spec = MOTIONS[id]
const frames: Pose[] = []
const n = Math.round(seconds * fps)
for (let i = 0; i < n; i++) {
const tSec = i / fps
const points = spec.poseAt(tSec / spec.cycleSeconds)
frames.push({ points, scores: points.map(() => 0.9), t: startMs + tSec * 1000 })
}
return frames
}
@@ -0,0 +1,96 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { TrainingSession, summarize, type SessionEvent } from '../session.ts'
import { MOTIONS } from '../motions.ts'
import { sampleMotion } from './sampleMotion.ts'
import type { Pose } from '../keypoints.ts'
const standing = (t: number): Pose => {
const points = MOTIONS.squat.poseAt(0)
return { points, scores: points.map(() => 0.9), t }
}
/** 站好 → 倒计时 → 开始训练,返回训练开始时刻 */
function warmUp(s: TrainingSession, events: SessionEvent[]) {
let t = 0
for (; t <= 5000; t += 100) events.push(...s.feed(standing(t), t))
return t
}
test('站好保持 1.5 秒后进入倒计时,3 秒后开始训练', () => {
const s = new TrainingSession('squat', 5)
const events: SessionEvent[] = []
warmUp(s, events)
const phases = events.filter((e) => e.type === 'phase').map((e) => (e as any).phase)
assert.deepEqual(phases, ['countdown', 'training'])
const counts = events.filter((e) => e.type === 'countdown').map((e) => (e as any).n)
assert.deepEqual(counts, [3, 2, 1])
})
test('没站进画面不会开始', () => {
const s = new TrainingSession('squat', 5)
for (let t = 0; t < 5000; t += 100) s.feed(null, t)
assert.equal(s.phase, 'framing')
assert.equal(s.framingIssue, 'no_body')
})
test('完成目标次数后结束,并统计时长与连击', () => {
const s = new TrainingSession('squat', 4)
const events: SessionEvent[] = []
const t0 = warmUp(s, events)
for (const f of sampleMotion('squat', MOTIONS.squat.cycleSeconds * 5, 12, t0)) {
events.push(...s.feed(f, f.t))
if (s.phase === 'done') break
}
assert.equal(s.phase, 'done')
assert.equal(s.count, 4)
assert.equal(s.bestCombo, 4)
assert.ok(events.some((e) => e.type === 'combo' && e.combo === 3))
const sum = summarize(s, 4)
assert.equal(sum.stars, 3)
assert.equal(sum.adviceKind, 'praise')
assert.ok(sum.durationSec >= 9 && sum.durationSec <= 14, `duration ${sum.durationSec}`)
assert.ok(sum.kcal > 0)
})
test('离开画面自动进入 lost,回来后继续,离开期间不计时', () => {
const s = new TrainingSession('squat', 10)
const t0 = warmUp(s, [])
s.feed(standing(t0 + 100), t0 + 100)
const before = s.activeMs
let t = t0 + 200
for (; t < t0 + 6000; t += 100) s.feed(null, t)
assert.equal(s.phase, 'lost')
assert.ok(s.activeMs - before < 2000, `active grew ${s.activeMs - before}`)
s.feed(standing(t), t)
assert.equal(s.phase, 'training')
})
test('暂停与恢复', () => {
const s = new TrainingSession('raise', 10)
const t0 = warmUp(s, [])
s.pause()
assert.equal(s.phase, 'paused')
const before = s.activeMs
s.tick(t0 + 10000)
assert.equal(s.activeMs, before)
s.resume(t0 + 10000)
assert.equal(s.phase, 'training')
})
test('领练模式:跳过站位、按节奏计数、不会因无人而暂停', () => {
const s = new TrainingSession('march', 3, { detectLost: false })
s.startCountdown(0)
assert.equal(s.phase, 'countdown')
for (let t = 0; t <= 3200; t += 200) s.tick(t)
assert.equal(s.phase, 'training')
for (let t = 3400; t < 9000; t += 200) s.tick(t)
assert.equal(s.phase, 'training')
s.addGuidedRep()
s.addGuidedRep()
const last = s.addGuidedRep()
assert.equal(s.count, 3)
assert.equal(s.phase, 'done')
assert.ok(last.some((e) => e.type === 'phase' && e.phase === 'done'))
assert.equal(s.reps.length, 0)
})
@@ -0,0 +1,26 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { toScreen } from '../viewport.ts'
const near = (a: number, b: number) => Math.abs(a - b) < 1e-6
test('比例一致时直接按尺寸缩放', () => {
const p = toScreen({ x: 0.25, y: 0.5 }, { width: 300, height: 400, frameWidth: 480, frameHeight: 640, mirror: false })
assert.ok(near(p.x, 75) && near(p.y, 200))
})
test('全屏竖屏按 aspectFill 裁掉左右两边,画面中心仍在屏幕中心', () => {
const vp = { width: 390, height: 844, frameWidth: 480, frameHeight: 640, mirror: false }
const c = toScreen({ x: 0.5, y: 0.5 }, vp)
assert.ok(near(c.x, 195) && near(c.y, 422))
const top = toScreen({ x: 0.5, y: 0 }, vp)
assert.ok(near(top.y, 0), '高度方向不裁剪')
const left = toScreen({ x: 0, y: 0.5 }, vp)
assert.ok(left.x < 0, '宽度方向被裁掉的部分落在屏幕外')
})
test('镜像预览左右翻转', () => {
const vp = { width: 300, height: 400, frameWidth: 480, frameHeight: 640, mirror: true }
const p = toScreen({ x: 0.2, y: 0.3 }, vp)
assert.ok(near(p.x, 240) && near(p.y, 120))
})
+179
View File
@@ -0,0 +1,179 @@
/**
* 训练背景音乐的播放控制(不依赖页面,便于测试)
*
* 一段首尾相接循环的舒缓音乐,音量比教练语音低一截,所有音量变化都是渐变:
* - 开始播放淡入;暂停 / 停止先淡出再真正暂停,不会突然出声或戛然而止;
* - 教练说话时压低(duck),压得快、恢复得慢,像电台主持人开口时压背景音乐;
* - 加载失败(地址不存在、断网)就当没有背景音乐:不再重试,也不打扰用户。
*/
export type BgmState = 'playing' | 'paused' | 'stopped'
/** 播放器的最小接口:小程序 InnerAudioContext 的子集,测试时可换成假的 */
export type BgmAudio = {
src: string
loop: boolean
volume: number
play(): void
pause(): void
stop(): void
destroy(): void
onError(cb: (err: { errMsg?: string; errCode?: number }) => void): void
}
export type BgmOptions = {
/** 音乐地址;为空表示没有背景音乐 */
url: string
create: () => BgmAudio
/** 正常播放时的音量(0~1,教练语音是 1) */
volume?: number
/** 定时器(测试时替换),返回取消函数 */
every?: (ms: number, fn: () => void) => () => void
/** 音乐不可用(加载失败)时回调,只回调一次 */
onUnavailable?: (reason: string) => void
}
const TICK_MS = 50
/** 音量从 0 变到正常音量所需的时长(ms) */
const RAMP_MS = { fade: 1600, duck: 250, unduck: 800 }
function defaultEvery(ms: number, fn: () => void) {
const id = setInterval(fn, ms)
return () => clearInterval(id)
}
export function createBgm(opts: BgmOptions) {
const base = opts.volume ?? 0.35
const every = opts.every ?? defaultEvery
let audio: BgmAudio | null = null
let failed = !opts.url
let state: BgmState = 'stopped'
let enabled = true
let duck = 1
let level = 0
/** 播放器在出声(play 之后、pause / stop 之前) */
let running = false
/** 播放位置在开头 */
let rewound = true
/** 当前这次渐变是淡入淡出,还是压低 / 恢复 */
let ramp: 'fade' | 'duck' = 'fade'
let cancel: (() => void) | null = null
const target = () => (state === 'playing' && enabled && !failed ? base * duck : 0)
function ensure(): BgmAudio | null {
if (failed) return null
if (!audio) {
const a = opts.create()
a.src = opts.url
a.loop = true
a.volume = 0
a.onError((err) => fail(err?.errMsg || `errCode ${err?.errCode ?? '?'}`))
audio = a
}
return audio
}
function fail(reason: string) {
if (failed) return
failed = true
release()
opts.onUnavailable?.(reason)
}
function stopTicker() {
cancel?.()
cancel = null
}
function release() {
stopTicker()
try {
audio?.destroy()
} catch (e) {}
audio = null
running = false
rewound = true
level = 0
}
/** 音量降到 0 之后才真正暂停 / 停止(停止会回到开头,下次从头播) */
function settle() {
if (!audio || level > 0) return
if (state === 'stopped') {
if (running || !rewound) audio.stop()
rewound = true
} else if (running) {
audio.pause()
}
running = false
}
function step() {
const t = target()
const ms = ramp === 'fade' ? RAMP_MS.fade : t < level ? RAMP_MS.duck : RAMP_MS.unduck
const max = (base * TICK_MS) / ms
level = Math.abs(t - level) <= max ? t : level + Math.sign(t - level) * max
if (audio) audio.volume = level
if (level === t) {
stopTicker()
settle()
}
}
function update(kind: 'fade' | 'duck') {
ramp = kind
const t = target()
if (t > 0) {
const a = ensure()
if (!a) return
if (!running) {
a.play()
running = true
rewound = false
}
}
if (level === t) {
stopTicker()
settle()
} else if (!cancel) {
cancel = every(TICK_MS, step)
}
}
return {
/** 提前建好播放器、开始缓冲(不出声),开始训练时能立刻响起来 */
prepare() {
if (enabled) ensure()
},
setState(next: BgmState) {
if (next === state) return
state = next
update('fade')
},
/** 音量压到正常音量的几成(1 = 不压) */
setDuck(ratio: number) {
const r = Math.min(1, Math.max(0, ratio))
if (r === duck) return
duck = r
update('duck')
},
setEnabled(on: boolean) {
if (on === enabled) return
enabled = on
update('fade')
},
/** 立即静音暂停:切到后台时定时器可能不再执行,不能靠淡出 */
hush() {
if (state === 'playing') state = 'paused'
stopTicker()
level = 0
if (audio) audio.volume = 0
settle()
},
destroy: release,
get available() {
return !failed
},
}
}
@@ -0,0 +1,51 @@
/**
* 摄像头权限
*
* 摄像头属于微信的隐私接口:小程序后台《用户隐私保护指引》没有声明“摄像头”时,
* wx.authorize 直接失败(errno 112),不弹授权框,设置页里也不会出现“摄像头”选项。
* 所以授权失败后不能一律引导用户去设置页,要先分清原因:
* denied 用户拒绝过 → 设置页里有这一项,引导去开启
* privacy_refused 用户没同意隐私保护指引 → 重新弹出隐私授权
* undeclared 后台没声明摄像头 → 用户侧无解,只能改用领练模式(开发者需补充隐私指引)
*/
export type CameraPermission = 'granted' | 'denied' | 'privacy_refused' | 'undeclared' | 'error'
type AuthError = { errno?: number; errMsg?: string } | undefined
/** 与 uni / wx 同名接口的最小子集,便于单测注入 */
export type PermissionApi = {
getSetting(opts: { success?: (res: { authSetting?: Record<string, boolean> }) => void; fail?: (err: AuthError) => void }): void
authorize(opts: { scope: string; success?: () => void; fail?: (err: AuthError) => void }): void
}
const SCOPE = 'scope.camera'
export function classifyAuthError(err: AuthError): CameraPermission {
const msg = String(err?.errMsg ?? '')
if (err?.errno === 112 || /not declared in the privacy agreement/i.test(msg)) return 'undeclared'
if (err?.errno === 104 || /privacy/i.test(msg)) return 'privacy_refused'
if (/deny|denied/i.test(msg)) return 'denied'
return 'error'
}
/** 先看设置里的授权状态:授权过直接用,拒绝过去设置页,从没问过才弹授权框 */
export function requestCameraPermission(api: PermissionApi): Promise<{ result: CameraPermission; errMsg: string }> {
return new Promise((resolve) => {
const authorize = () =>
api.authorize({
scope: SCOPE,
success: () => resolve({ result: 'granted', errMsg: '' }),
fail: (err) => resolve({ result: classifyAuthError(err), errMsg: String(err?.errMsg ?? '') }),
})
api.getSetting({
success: (res) => {
const v = res?.authSetting?.[SCOPE]
if (v === true) resolve({ result: 'granted', errMsg: '' })
else if (v === false) resolve({ result: 'denied', errMsg: '' })
else authorize()
},
fail: () => authorize(),
})
})
}
@@ -0,0 +1,81 @@
/**
* 教练“小甄”帧资源
*
* 动作帧是 AI 画的原图加少量插帧(由 training-videos/scripts 生成并对齐),内置在 training/static/coach。
* 每个动作的一侧 10~24 帧,出自同一张设定稿并统一了脸(前后帧一致,不会闪);
* 每帧带实测的动作幅度,按幅度查帧,帧间距不均匀时播放速度仍然均匀。
*/
import type { MoveId, Side } from './motions.ts'
import { COACH_HD, COACH_SEQUENCES, type CoachSequence } from './coachSequences.ts'
export type Gesture = 'wave' | 'cheer' | 'thumbs'
const MOVE_SEQUENCE: Record<MoveId, Record<Side, string>> = {
squat: { L: 'squat', R: 'squat' },
march: { L: 'march_l', R: 'march_r' },
jack: { L: 'jack_l', R: 'jack_r' },
raise: { L: 'raise', R: 'raise' },
}
/** 错误示范帧(示范视频对比用) */
export const MISTAKE_FRAMES: Record<MoveId, string> = {
squat: 'wrong_squat',
march: 'wrong_march',
jack: 'wrong_jack',
raise: 'wrong_raise',
}
function seqOf(move: MoveId, side: Side): CoachSequence {
return COACH_SEQUENCES[MOVE_SEQUENCE[move][side]]
}
export function sequenceOf(move: MoveId, side: Side): string[] {
return seqOf(move, side).frames
}
/** 动作到位的那一帧(封面、错误示范对比用) */
export function peakFrame(move: MoveId): string {
const frames = sequenceOf(move, 'L')
return frames[frames.length - 1]
}
export function frameUrl(name: string): string {
return `/training/static/coach/${name}.png`
}
/** 高清帧地址(COS):带内容版本号,重新上传同名文件后缓存自动刷新 */
export function hdFrameUrl(base: string, name: string): string {
return `${base}/${name}.webp?v=${COACH_HD.version}`
}
/** 高清帧的裁切框:在整张画布上的比例 [左, 上, 右, 下],用来把裁过的图放回原位 */
export function hdCropOf(move: MoveId): [number, number, number, number] {
return COACH_HD.crops[move]
}
/** 播放某个动作需要加载的全部帧 */
export function framesToLoad(move: MoveId): string[] {
return [...new Set([...sequenceOf(move, 'L'), ...sequenceOf(move, 'R')])]
}
export type FramePair = { a: string; b: string; w: number }
/** 动作幅度 → 相邻两帧与两帧之间的位置(0~1),按每帧的实测幅度插值 */
export function framePair(move: MoveId, amount: number, side: Side): FramePair {
const { frames, amounts } = seqOf(move, side)
const x = Math.min(1, Math.max(0, amount))
let i = 0
while (i < frames.length - 2 && amounts[i + 1] <= x) i += 1
const span = amounts[i + 1] - amounts[i]
const w = span > 0 ? Math.min(1, Math.max(0, (x - amounts[i]) / span)) : 1
return { a: frames[i], b: frames[i + 1], w }
}
/**
* 播放时的混合权重:大部分时间显示单张清晰帧,只在换帧的瞬间短暂过渡。
* 相邻两帧一直按比例混合的话,移动中的手臂会出现半透明的双影。
*/
export function crispBlend(w: number): number {
const t = Math.min(1, Math.max(0, (w - 0.44) / 0.12))
return t * t * (3 - 2 * t)
}
@@ -0,0 +1,53 @@
/**
* 教练动作帧清单(由 training-videos/scripts/coach_export.py 自动生成,勿手改)
*
* 每个序列按动作幅度 0→1 排列,全部是 AI 原图帧(内置在 training/static/coach),
* 同一个动作出自同一张设定稿;右侧动作由左侧镜像换边生成。amounts 是每帧实测的动作幅度。
*/
export type CoachSequence = {
/** 帧名,按动作幅度 0→1 排列 */
frames: string[]
/** 每帧的动作幅度(0 = 起始姿势,1 = 动作到位),与 frames 一一对应、严格递增 */
amounts: number[]
}
export const COACH_SEQUENCES: Record<string, CoachSequence> = {
squat: {
frames: ['sq_00', 'sq_01', 'sq_02', 'sq_03', 'sq_04', 'sq_05', 'sq_06', 'sq_07', 'sq_08', 'sq_09', 'sq_10', 'sq_11', 'sq_12', 'sq_13', 'sq_14', 'sq_15', 'sq_16', 'sq_17', 'sq_18', 'sq_19', 'sq_20', 'sq_21', 'sq_22', 'sq_23'],
amounts: [0, 0.057, 0.077, 0.133, 0.168, 0.266, 0.304, 0.342, 0.383, 0.424, 0.456, 0.488, 0.528, 0.569, 0.609, 0.648, 0.702, 0.729, 0.781, 0.834, 0.859, 0.92, 0.957, 1],
},
raise: {
frames: ['ra_00', 'ra_01', 'ra_02', 'ra_03', 'ra_04', 'ra_05', 'ra_06', 'ra_07', 'ra_08', 'ra_09', 'ra_10', 'ra_11', 'ra_12', 'ra_13', 'ra_14', 'ra_15', 'ra_16', 'ra_17', 'ra_18', 'ra_19', 'ra_20', 'ra_21', 'ra_22', 'ra_23'],
amounts: [0, 0.118, 0.162, 0.206, 0.245, 0.285, 0.323, 0.36, 0.398, 0.437, 0.461, 0.485, 0.512, 0.579, 0.617, 0.645, 0.708, 0.743, 0.771, 0.821, 0.866, 0.909, 0.957, 1],
},
jack_l: {
frames: ['jl_00', 'jl_01', 'jl_02', 'jl_03', 'jl_04', 'jl_05', 'jl_06', 'jl_07', 'jl_08', 'jl_09'],
amounts: [0, 0.105, 0.239, 0.316, 0.463, 0.539, 0.677, 0.782, 0.876, 1],
},
jack_r: {
frames: ['jl_00', 'jr_01', 'jr_02', 'jr_03', 'jr_04', 'jr_05', 'jr_06', 'jr_07', 'jr_08', 'jr_09'],
amounts: [0, 0.105, 0.239, 0.316, 0.463, 0.539, 0.677, 0.782, 0.876, 1],
},
march_l: {
frames: ['ml_00', 'ml_01', 'ml_02', 'ml_03', 'ml_04', 'ml_05', 'ml_06', 'ml_07', 'ml_08', 'ml_09', 'ml_10', 'ml_11', 'ml_12'],
amounts: [0, 0.025, 0.25, 0.303, 0.356, 0.427, 0.499, 0.576, 0.638, 0.728, 0.755, 0.877, 1],
},
march_r: {
frames: ['ml_00', 'mr_01', 'mr_02', 'mr_03', 'mr_04', 'mr_05', 'mr_06', 'mr_07', 'mr_08', 'mr_09', 'mr_10', 'mr_11', 'mr_12'],
amounts: [0, 0.025, 0.25, 0.303, 0.356, 0.427, 0.499, 0.576, 0.638, 0.728, 0.755, 0.877, 1],
},
}
/**
* 高清帧(training-videos/out/coach 上传到 COS):每个动作裁出人物所在区域,
* crops 是裁切框在整张画布上的比例 [左, 上, 右, 下];version 随内容变化,用来刷新 CDN 缓存。
*/
export const COACH_HD: { version: string; crops: Record<string, [number, number, number, number]> } = {
version: '7ea1f74601',
crops: {
squat: [0.358, 0.219, 0.642, 0.962],
raise: [0.169, 0.218, 0.822, 0.962],
jack: [0.173, 0.13, 0.825, 0.962],
march: [0.362, 0.218, 0.64, 0.961],
},
}
+418
View File
@@ -0,0 +1,418 @@
/**
* 动作计数与纠错
*
* 每个动作一个“单指标 + 滞回阈值”的状态机:
* 指标越过 enter 阈值 → 进入动作中,记录峰值与动作期间的姿态问题
* 指标回落到 exit 阈值 → 结束一次,按峰值评分并给出提示
* 阈值全部是身体比例或关节角度,与人离镜头远近无关。
*
* 纯逻辑,不依赖 uni / wx,可直接用 Node 单测。
*/
import { KP, MIN_POINT_SCORE, angleAt, isVisible, mid, pt, shoulderWidth, type Point, type Pose } from './keypoints.ts'
import type { Amount, MoveId, Side } from './motions.ts'
export type Grade = 'perfect' | 'good' | 'ok'
export type CoachEvent =
| { type: 'rep'; count: number; score: number; grade: Grade; tip?: string; joints?: number[] }
| { type: 'tip'; text: string; joints?: number[] }
export interface RepCounter {
readonly move: MoveId
readonly count: number
/** 当前帧的动作幅度(0~1),镜像模式用它驱动教练 */
readonly live: Amount
/** 计一次所需的动作幅度(进入阈值换算成 0~1),界面幅度条上的目标线 */
readonly goal: number
/** 最近一帧的原始指标;关键点看不清、这一帧没算出来时为 null(诊断用) */
readonly metric: number | null
update(pose: Pose): CoachEvent[]
reset(): void
}
/** 一次动作过程中收集的信息 */
type RepTrace = {
peak: number
startT: number
/** 动作期间出现过的问题(取最严重的一次) */
flags: Set<string>
}
type Rule = {
/** 当前帧指标;关键点不可见时返回 null */
metric: (pose: Pose, ctx: Ctx) => number | null
enter: number
exit: number
/** 峰值达到 attempt 但没到 enter,回落时提示“幅度不够” */
attempt: number
attemptTip: string
attemptJoints: number[]
/** 峰值 → 分数与提示 */
judge: (peak: number, trace: RepTrace) => { score: number; tip?: string; joints?: number[] }
/** 动作进行中逐帧检查姿态 */
checkForm?: (pose: Pose, ctx: Ctx, flags: Set<string>) => void
/** 最短单次时长(ms),更快则提示放慢 */
minRepMs?: number
/** 连续几帧回到起始位才算完成一次(默认 1):在起始位会停留的动作设 2,单帧识别抖动不会多计 */
exitFrames?: number
/** 指标 → 动作幅度(0 = 起始姿势,1 = 标准到位) */
amount: (v: number) => number
/** 左右交替的动作:当前是哪一侧 */
side?: (pose: Pose) => Side
}
type Ctx = {
/** 站立基线(腿长等),缓慢自适应 */
baseline: Record<string, number>
}
/** 纠错提示文案(语音会在训练前预合成,改文案只需改这里) */
export const TIPS = {
squat: {
shallowAttempt: '再蹲低一点,屁股向后坐',
valgus: '膝盖向外打开,对准脚尖',
shallow: '不错,下次再蹲深一点',
},
march: {
lowAttempt: '膝盖再抬高一点',
low: '膝盖抬到腰的高度更有效',
},
jack: {
armsAttempt: '双手举过头顶',
step: '手举起来时,脚向外迈一步',
},
raise: {
lowAttempt: '手臂再抬高,与肩同高',
shrug: '肩膀下沉,不要耸肩',
high: '抬到与肩同高就好,不用更高',
asym: '两只手一起抬,保持平衡',
},
} as const
export const TEMPO_TIP = '慢一点,控制好节奏'
/** 某个动作可能播报的全部提示 */
export function tipTextsOf(move: MoveId): string[] {
return [...Object.values(TIPS[move]), TEMPO_TIP]
}
const TIP_COOLDOWN_MS = 3500
/** 只是建议、不影响计数和评分的提示,冷却更久,免得每次都念 */
const ADVICE_TIPS: ReadonlySet<string> = new Set([TIPS.jack.step])
const ADVICE_COOLDOWN_MS = 20000
/**
* 连续几帧达到计数线才算动作开始:识别引擎偶尔有单帧跳动(真机:高抬腿一帧从 0.05 跳到 0.46 又落回),
* 真实动作在计数线以上至少持续 0.3 秒,两帧确认不影响正常计数
*/
const ENTER_FRAMES = 2
const gradeOf = (score: number): Grade => (score >= 90 ? 'perfect' : score >= 75 ? 'good' : 'ok')
const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v))
/** 线性映射到分数区间 */
const scale = (v: number, v0: number, v1: number, s0: number, s1: number) => s0 + (s1 - s0) * clamp((v - v0) / (v1 - v0), 0, 1)
/**
* 计数用的关键点可见阈值,与站位一致。
* 真机实测:离得太近、腿被画面裁掉时,腿部点置信度只有 0.1~0.2,位置是模型猜的(膝盖跑到髋上方、脚踝乱飘),
* 放宽到 0.2 会把这些点拿来计数、没下蹲也计数。看不清时宁可不计,由页面提示用户后退。
*/
export const TRACK_MIN_SCORE = MIN_POINT_SCORE
function visibleAll(pose: Pose, idx: number[]) {
return idx.every((i) => isVisible(pose, i, TRACK_MIN_SCORE))
}
/** 一组点里看得清的那些的平均位置(左右成对的点只看清一个也能用);一个都看不清返回 null */
function avgVisible(pose: Pose, idx: number[]): Point | null {
const seen = idx.filter((i) => isVisible(pose, i, TRACK_MIN_SCORE)).map((i) => pt(pose, i))
if (!seen.length) return null
return { x: seen.reduce((s, p) => s + p.x, 0) / seen.length, y: seen.reduce((s, p) => s + p.y, 0) / seen.length }
}
/** 躯干长度:看得清的肩、髋的中点之间的距离 */
function visibleTorso(pose: Pose): number | null {
const sh = avgVisible(pose, [KP.L_SHOULDER, KP.R_SHOULDER])
const hip = avgVisible(pose, [KP.L_HIP, KP.R_HIP])
if (!sh || !hip) return null
return Math.hypot(sh.x - hip.x, sh.y - hip.y)
}
/** 肩外展角:躯干侧线(髋→肩)与手臂(肩→腕)的夹角 */
function abduction(pose: Pose, side: 'L' | 'R'): number | null {
const [sh, hip, wr] = side === 'L' ? [KP.L_SHOULDER, KP.L_HIP, KP.L_WRIST] : [KP.R_SHOULDER, KP.R_HIP, KP.R_WRIST]
if (!visibleAll(pose, [sh, hip, wr])) return null
return angleAt(pt(pose, hip), pt(pose, sh), pt(pose, wr))
}
function avgAbduction(pose: Pose): number | null {
const l = abduction(pose, 'L')
const r = abduction(pose, 'R')
if (l === null && r === null) return null
if (l === null) return r
if (r === null) return l
return (l + r) / 2
}
// ─────────────────────────── 各动作规则 ───────────────────────────
/**
* 深蹲:髋部下降比例 = 1 - 当前(踝→髋高度) / 站立时(踝→髋高度)。左右髋、左右踝各看清一个就够。
* 不用髋→膝高度做替补:真机上脚踝看不清时膝盖往往也是猜的,两种估算逐帧交替会算出 0 和 0.4 来回跳,凭空计数。
*/
function squatRule(): Rule {
return {
metric(pose, ctx) {
const hip = avgVisible(pose, [KP.L_HIP, KP.R_HIP])
const ankle = avgVisible(pose, [KP.L_ANKLE, KP.R_ANKLE])
if (!hip || !ankle) return null
const span = ankle.y - hip.y
const b = ctx.baseline
// 站立基线:取近期最大值,并缓慢衰减以适应前后走动
b.legSpan = Math.max(span, (b.legSpan ?? span) * 0.998)
return b.legSpan > 0 ? clamp(1 - span / b.legSpan, 0, 1) : null
},
enter: 0.13,
exit: 0.06,
attempt: 0.08,
attemptTip: TIPS.squat.shallowAttempt,
amount: (v) => clamp(v / 0.34, 0, 1),
attemptJoints: [KP.L_HIP, KP.R_HIP],
minRepMs: 1200,
exitFrames: 2,
checkForm(pose, _ctx, flags) {
if (!visibleAll(pose, [KP.L_KNEE, KP.R_KNEE, KP.L_ANKLE, KP.R_ANKLE])) return
const kneeGap = Math.abs(pt(pose, KP.L_KNEE).x - pt(pose, KP.R_KNEE).x)
const ankleGap = Math.abs(pt(pose, KP.L_ANKLE).x - pt(pose, KP.R_ANKLE).x)
const hipDrop = legSpanDropFlag(pose)
if (hipDrop && ankleGap > 0 && kneeGap / ankleGap < 0.8) flags.add('valgus')
},
judge(peak, trace) {
let score = scale(peak, 0.13, 0.3, 70, 100)
if (trace.flags.has('valgus')) {
score = Math.min(score, 72)
return { score, tip: TIPS.squat.valgus, joints: [KP.L_KNEE, KP.R_KNEE] }
}
if (peak < 0.24) return { score, tip: TIPS.squat.shallow, joints: [KP.L_HIP, KP.R_HIP] }
return { score }
},
}
}
/** checkForm 只在蹲下时判断膝内扣,用躯干长度估计是否已明显下蹲 */
function legSpanDropFlag(pose: Pose): boolean {
const hip = mid(pt(pose, KP.L_HIP), pt(pose, KP.R_HIP))
const ankle = mid(pt(pose, KP.L_ANKLE), pt(pose, KP.R_ANKLE))
const torso = visibleTorso(pose) ?? 0
return torso > 0 && (ankle.y - hip.y) / torso < 1.25
}
/** 高抬腿:抬起侧膝盖比支撑侧膝盖高出多少(按躯干长度归一;肩、髋各看清一侧即可) */
function marchRule(): Rule {
return {
metric(pose) {
if (!visibleAll(pose, [KP.L_KNEE, KP.R_KNEE])) return null
const torso = visibleTorso(pose)
if (!torso) return null
const diff = Math.abs(pt(pose, KP.L_KNEE).y - pt(pose, KP.R_KNEE).y)
return diff / torso
},
enter: 0.16,
exit: 0.06,
attempt: 0.08,
attemptTip: TIPS.march.lowAttempt,
amount: (v) => clamp(v / 0.55, 0, 1),
side: (pose) => (pt(pose, KP.L_KNEE).y < pt(pose, KP.R_KNEE).y ? 'L' : 'R'),
attemptJoints: [KP.L_KNEE, KP.R_KNEE],
minRepMs: 250,
judge(peak) {
const score = scale(peak, 0.16, 0.45, 72, 100)
if (peak < 0.3) return { score, tip: TIPS.march.low, joints: [KP.L_KNEE, KP.R_KNEE] }
return { score }
},
}
}
/** 低冲击开合:双臂外展角;同时检查脚是否向外迈开 */
function jackRule(): Rule {
return {
metric: (pose) => avgAbduction(pose),
// 手臂高过肩就算(只举到与肩平约 90° 仍不计)。真机实测跟练用户的峰值多在 99°~110°,远低于示范的 178°
enter: 95,
exit: 50,
attempt: 65,
attemptTip: TIPS.jack.armsAttempt,
amount: (v) => clamp((v - 12) / 158, 0, 1),
side(pose) {
const c = mid(pt(pose, KP.L_HIP), pt(pose, KP.R_HIP)).x
const l = Math.abs(pt(pose, KP.L_ANKLE).x - c)
const r = Math.abs(pt(pose, KP.R_ANKLE).x - c)
return l >= r ? 'L' : 'R'
},
attemptJoints: [KP.L_WRIST, KP.R_WRIST],
minRepMs: 500,
checkForm(pose, ctx, flags) {
if (!visibleAll(pose, [KP.L_ANKLE, KP.R_ANKLE, KP.L_SHOULDER, KP.R_SHOULDER])) return
const sw = shoulderWidth(pose)
if (sw <= 0) return
const spread = Math.abs(pt(pose, KP.L_ANKLE).x - pt(pose, KP.R_ANKLE).x) / sw
ctx.baseline.maxSpread = Math.max(ctx.baseline.maxSpread ?? 0, spread)
if (spread >= 1.05) flags.add('stepped')
},
judge(peak, trace) {
// 满分线按真机跟练的实际幅度定(示范 178°,用户多在 100°~130°):举到 140° 就是满分
const score = scale(peak, 95, 140, 75, 100)
// 没迈步只提示、不压分:手臂到位才是评分依据(提示冷却较长,不会每次都念)
if (!trace.flags.has('stepped')) return { score, tip: TIPS.jack.step, joints: [KP.L_ANKLE, KP.R_ANKLE] }
return { score }
},
}
}
/** 侧平举:双臂外展角,理想峰值 80~100 度;同时检查耸肩与左右不对称 */
function raiseRule(): Rule {
const neckRatio = (pose: Pose) => {
const sh = mid(pt(pose, KP.L_SHOULDER), pt(pose, KP.R_SHOULDER))
const sw = shoulderWidth(pose)
return sw > 0 ? (sh.y - pt(pose, KP.NOSE).y) / sw : null
}
return {
metric(pose, ctx) {
const a = avgAbduction(pose)
if (a !== null && a < 30 && isVisible(pose, KP.NOSE)) {
// 手臂放下时记录颈部基线,用于判断耸肩
const r = neckRatio(pose)
if (r !== null) ctx.baseline.neck = ctx.baseline.neck === undefined ? r : ctx.baseline.neck * 0.9 + r * 0.1
}
return a
},
enter: 58,
exit: 30,
attempt: 38,
attemptTip: TIPS.raise.lowAttempt,
amount: (v) => clamp((v - 10) / 78, 0, 1),
attemptJoints: [KP.L_WRIST, KP.R_WRIST],
minRepMs: 1200,
checkForm(pose, ctx, flags) {
const l = abduction(pose, 'L')
const r = abduction(pose, 'R')
if (l !== null && r !== null && Math.abs(l - r) > 28) flags.add('asym')
const base = ctx.baseline.neck
const cur = isVisible(pose, KP.NOSE) ? neckRatio(pose) : null
if (base && cur !== null && cur < base * 0.8) flags.add('shrug')
},
judge(peak, trace) {
if (trace.flags.has('shrug')) return { score: 70, tip: TIPS.raise.shrug, joints: [KP.L_SHOULDER, KP.R_SHOULDER] }
if (peak > 112) return { score: 75, tip: TIPS.raise.high, joints: [KP.L_WRIST, KP.R_WRIST] }
if (trace.flags.has('asym')) return { score: 78, tip: TIPS.raise.asym, joints: [KP.L_WRIST, KP.R_WRIST] }
const score = peak >= 80 ? 100 : scale(peak, 58, 80, 75, 95)
return { score }
},
}
}
const RULES: Record<MoveId, () => Rule> = {
squat: squatRule,
march: marchRule,
jack: jackRule,
raise: raiseRule,
}
// ─────────────────────────── 通用状态机 ───────────────────────────
export function createCounter(move: MoveId): RepCounter {
const rule = RULES[move]()
let ctx: Ctx = { baseline: {} }
let count = 0
let active: RepTrace | null = null
let attemptPeak = 0
let lastRepEnd = 0
let exitStreak = 0
let enterStreak = 0
let live: Amount = { amount: 0, side: 'L' }
let metric: number | null = null
const goal = rule.amount(rule.enter)
const lastTipAt = new Map<string, number>()
const allowTip = (text: string, t: number) => {
const last = lastTipAt.get(text) ?? -Infinity
if (t - last < (ADVICE_TIPS.has(text) ? ADVICE_COOLDOWN_MS : TIP_COOLDOWN_MS)) return false
lastTipAt.set(text, t)
return true
}
return {
move,
get count() {
return count
},
get live() {
return live
},
get metric() {
return metric
},
goal,
reset() {
ctx = { baseline: {} }
count = 0
active = null
attemptPeak = 0
lastRepEnd = 0
exitStreak = 0
enterStreak = 0
live = { amount: 0, side: 'L' }
metric = null
lastTipAt.clear()
},
update(pose) {
const events: CoachEvent[] = []
const raw = rule.metric(pose, ctx)
// 兜底:指标算出 NaN / Infinity(坐标异常)时按看不清处理,不让幅度条和计数卡住
const v = raw !== null && Number.isFinite(raw) ? raw : null
metric = v
if (v === null) return events
live = { amount: rule.amount(v), side: rule.side?.(pose) ?? 'L' }
if (!active) {
enterStreak = v >= rule.enter ? enterStreak + 1 : 0
if (enterStreak >= ENTER_FRAMES) {
enterStreak = 0
active = { peak: Math.max(v, attemptPeak), startT: pose.t, flags: new Set() }
attemptPeak = 0
exitStreak = 0
} else if (v >= rule.attempt) {
attemptPeak = Math.max(attemptPeak, v)
} else if (attemptPeak > 0 && v <= rule.exit) {
// 做了动作但幅度不够,回到起始位
if (allowTip(rule.attemptTip, pose.t)) events.push({ type: 'tip', text: rule.attemptTip, joints: rule.attemptJoints })
attemptPeak = 0
}
}
if (active) {
active.peak = Math.max(active.peak, v)
rule.checkForm?.(pose, ctx, active.flags)
exitStreak = v <= rule.exit ? exitStreak + 1 : 0
if (exitStreak >= (rule.exitFrames ?? 1)) {
exitStreak = 0
count += 1
const judged = rule.judge(active.peak, active)
let { tip, joints } = judged
const duration = pose.t - (lastRepEnd || active.startT)
if (!tip && rule.minRepMs && lastRepEnd && duration < rule.minRepMs) {
tip = TEMPO_TIP
joints = undefined
}
const score = Math.round(judged.score)
const showTip = tip && allowTip(tip, pose.t) ? tip : undefined
events.push({ type: 'rep', count, score, grade: gradeOf(score), tip: showTip, joints: showTip ? joints : undefined })
lastRepEnd = pose.t
active = null
}
}
return events
},
}
}
+133
View File
@@ -0,0 +1,133 @@
/**
* 跟练动作的展示文案与训练参数
*
* 纯数据,小程序页面与 Remotion 示范视频共用。
*/
import type { MoveId } from './motions.ts'
export type ExerciseInfo = {
id: MoveId
name: string
/** 一句话卖点 */
tagline: string
/** 锻炼部位 */
targets: string[]
/** 强度:1 轻松 ~ 3 较累 */
level: 1 | 2 | 3
/** 默认目标次数 */
targetReps: number
/** 目标次数可调范围与步长 */
repRange: [number, number]
repStep: number
/** 估算代谢当量(净 MET,已扣除静息 1 MET) */
netMet: number
/** 动作要领(示范视频逐条出现) */
keyPoints: string[]
/** 常见错误 → 正确做法 */
mistake: { wrong: string; right: string }
/** 主题色 */
color: string
/** 示范视频地址;上传 COS 后填入,留空时正式环境不显示“完整示范”按钮 */
videoUrl: string
}
export const EXERCISES: Record<MoveId, ExerciseInfo> = {
squat: {
id: 'squat',
name: '徒手深蹲',
tagline: '练大腿和臀部,大肌群最耗糖',
targets: ['大腿', '臀部', '核心'],
level: 2,
targetReps: 12,
repRange: [6, 30],
repStep: 2,
netMet: 4,
keyPoints: ['双脚与肩同宽,脚尖微微向外', '屁股向后坐,像要坐椅子', '膝盖对准脚尖,不要内扣', '蹲到大腿接近水平,脚跟发力站起'],
mistake: { wrong: '膝盖向内扣', right: '膝盖朝脚尖方向打开' },
color: '#10b981',
videoUrl: 'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/yundong/video/squat.mp4',
},
march: {
id: 'march',
name: '原地高抬腿',
tagline: '不出门的有氧,饭后 10 分钟正合适',
targets: ['下肢', '心肺'],
level: 1,
targetReps: 40,
repRange: [20, 100],
repStep: 10,
netMet: 3,
keyPoints: ['上身挺直,目视前方', '左右交替抬膝', '膝盖尽量抬到腰的高度', '手臂自然前后摆动'],
mistake: { wrong: '膝盖只抬一点点', right: '大腿抬到接近水平' },
color: '#f59e0b',
videoUrl: 'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/yundong/video/march.mp4',
},
jack: {
id: 'jack',
name: '低冲击开合',
tagline: '不用跳,对膝盖友好的全身燃脂',
targets: ['全身', '肩部', '心肺'],
level: 2,
targetReps: 20,
repRange: [8, 40],
repStep: 4,
netMet: 3.5,
keyPoints: ['一只脚向侧面迈出一步', '同时双手从两侧举过头顶', '收脚时手臂放下', '左右交替,保持节奏'],
mistake: { wrong: '手臂只抬到胸口', right: '双手完全举过头顶' },
color: '#6366f1',
videoUrl: 'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/yundong/video/jack.mp4',
},
raise: {
id: 'raise',
name: '站姿侧平举',
tagline: '坐久了肩颈僵?练练肩膀',
targets: ['肩部', '上背'],
level: 1,
targetReps: 15,
repRange: [9, 30],
repStep: 3,
netMet: 2,
keyPoints: ['双脚与肩同宽,微收腹', '手臂伸直,从两侧慢慢抬起', '抬到与肩同高停一秒', '慢慢放下,肩膀不要耸起'],
mistake: { wrong: '耸肩、手臂抬过头', right: '肩膀下沉,抬到与肩平齐' },
color: '#0ea5e9',
videoUrl: 'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/yundong/video/raise.mp4',
},
}
export const EXERCISE_ORDER: MoveId[] = ['squat', 'march', 'jack', 'raise']
/**
* 示范视频地址:线上用 COS 地址(上传后填到 EXERCISES[x].videoUrl);
* 开发环境回退到本机 training-videos 静态服务(npm run serve,端口 5179),
* 真机调试时把 DEV_VIDEO_HOST 改成电脑的局域网 IP。
*/
export const DEV_VIDEO_HOST = 'http://127.0.0.1:5179'
export function videoUrlOf(ex: ExerciseInfo, isDev: boolean): string {
if (ex.videoUrl) return ex.videoUrl
return isDev ? `${DEV_VIDEO_HOST}/${ex.id}.mp4` : ''
}
/**
* 教练高清帧的 COS 目录:把 training-videos/out/coach 整个上传后填入,
* 例如 'https://xxx.cos.ap-guangzhou.myqcloud.com/training/coach'(末尾不带斜杠)。
* 留空只用包内帧;开发环境留空时读本机 npm run serve(真机访问不到会自动退回包内帧)。
*/
export const COACH_CDN_BASE = 'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/yundong/coach'
export function coachCdnBase(isDev: boolean): string {
if (COACH_CDN_BASE) return COACH_CDN_BASE
return isDev ? `${DEV_VIDEO_HOST}/coach` : ''
}
/**
* 跟练背景音乐:training-videos 里 npm run bgm 生成的 out/music/calm.mp3 上传到 COS 后填入;
* 换音乐时换个文件名(避免缓存)。留空时开发环境读本机 npm run serve,正式环境不放音乐;
* 地址打不开时自动不放、隐藏开关,不影响训练。
*/
export const BGM_URL = 'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/file/20260930/20260930085356d4f920988.mp3'
export function bgmUrlOf(isDev: boolean): string {
if (BGM_URL) return BGM_URL
return isDev ? `${DEV_VIDEO_HOST}/music/calm.mp3` : ''
}
+92
View File
@@ -0,0 +1,92 @@
/**
* 跟练记录(本地存储)
*
* 只保存汇总数据,不含任何画面。纯函数部分可单测,存取通过 storage 适配器注入。
*/
import type { MoveId } from './motions.ts'
import type { SessionSummary } from './session.ts'
export type HistoryRecord = {
move: MoveId
reps: number
avgScore: number
durationSec: number
kcal: number
/** 本地日期 YYYY-MM-DD */
day: string
at: number
}
export type Storage = {
get: (key: string) => unknown
set: (key: string, value: unknown) => void
}
const KEY = 'pose_coach_history_v1'
const MAX_RECORDS = 200
export function dayOf(ts: number): string {
const d = new Date(ts)
const mm = String(d.getMonth() + 1).padStart(2, '0')
const dd = String(d.getDate()).padStart(2, '0')
return `${d.getFullYear()}-${mm}-${dd}`
}
export function loadHistory(store: Storage): HistoryRecord[] {
try {
const raw = store.get(KEY)
return Array.isArray(raw) ? (raw as HistoryRecord[]) : []
} catch (e) {
return []
}
}
export function appendHistory(store: Storage, s: SessionSummary, now = Date.now()): HistoryRecord[] {
if (s.reps <= 0) return loadHistory(store)
const rec: HistoryRecord = {
move: s.move,
reps: s.reps,
avgScore: s.avgScore,
durationSec: s.durationSec,
kcal: s.kcal,
day: dayOf(now),
at: now,
}
const next = [rec, ...loadHistory(store)].slice(0, MAX_RECORDS)
try {
store.set(KEY, next)
} catch (e) {}
return next
}
export type HistoryStats = {
todayReps: number
todayMinutes: number
/** 连续训练天数(含今天;今天没练则从昨天算起) */
streak: number
total: number
}
export function statsOf(records: HistoryRecord[], now = Date.now()): HistoryStats {
const today = dayOf(now)
const todays = records.filter((r) => r.day === today)
const days = new Set(records.map((r) => r.day))
let streak = 0
let cursor = days.has(today) ? now : now - 86400000
while (days.has(dayOf(cursor))) {
streak += 1
cursor -= 86400000
}
return {
todayReps: todays.reduce((a, r) => a + r.reps, 0),
todayMinutes: Math.round(todays.reduce((a, r) => a + r.durationSec, 0) / 60),
streak,
total: records.length,
}
}
/** uni 本地存储适配器 */
export const uniStorage: Storage = {
get: (k) => uni.getStorageSync(k),
set: (k, v) => uni.setStorageSync(k, v),
}
+194
View File
@@ -0,0 +1,194 @@
/**
* 人体关键点定义与几何工具
*
* 点位遵循微信 VisionKit body 检测的 23 点定义(基础库 >= 2.28.0):
* 0 鼻子 1/2 左/右眼 3/4 左/右耳
* 5/6 左/右肩 7/8 左/右肘 9/10 左/右腕
* 11/12 左/右髋 13/14 左/右膝 15/16 左/右踝
* 17/18 左/右手尖 19/20 左/右脚外侧 21/22 左/右脚内侧
* “左/右”指被拍摄者自身的左右。
*
* 本文件不依赖 uni / wx,可在 Node 单测与 Remotion 中直接复用。
*/
export type Point = { x: number; y: number }
/** 一帧姿态:23 个点,坐标归一化到 [0,1],score 为该点置信度(0~1) */
export type Pose = {
points: Point[]
scores: number[]
/** 采集时间戳(ms) */
t: number
/** 画面宽高比(宽/高),用于把 x 换算到与 y 同一尺度;缺省为 1 */
aspect?: number
}
/** 取某个点,并把 x 换算到与 y 相同的尺度(角度、距离计算必须用它) */
export function pt(pose: Pose, idx: number): Point {
const p = pose.points[idx]
return { x: p.x * (pose.aspect ?? 1), y: p.y }
}
export const KP = {
NOSE: 0,
L_EYE: 1,
R_EYE: 2,
L_EAR: 3,
R_EAR: 4,
L_SHOULDER: 5,
R_SHOULDER: 6,
L_ELBOW: 7,
R_ELBOW: 8,
L_WRIST: 9,
R_WRIST: 10,
L_HIP: 11,
R_HIP: 12,
L_KNEE: 13,
R_KNEE: 14,
L_ANKLE: 15,
R_ANKLE: 16,
L_HAND: 17,
R_HAND: 18,
L_FOOT_OUT: 19,
R_FOOT_OUT: 20,
L_FOOT_IN: 21,
R_FOOT_IN: 22,
} as const
export const KEYPOINT_COUNT = 23
/** 判定“全身入镜”需要看得到的点 */
export const FULL_BODY_POINTS = [
KP.NOSE,
KP.L_SHOULDER,
KP.R_SHOULDER,
KP.L_HIP,
KP.R_HIP,
KP.L_KNEE,
KP.R_KNEE,
KP.L_ANKLE,
KP.R_ANKLE,
]
export const MIN_POINT_SCORE = 0.3
export function dist(a: Point, b: Point): number {
return Math.hypot(a.x - b.x, a.y - b.y)
}
export function mid(a: Point, b: Point): Point {
return { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 }
}
/** 以 b 为顶点的夹角 ∠abc,单位度,范围 [0,180] */
export function angleAt(a: Point, b: Point, c: Point): number {
const v1x = a.x - b.x
const v1y = a.y - b.y
const v2x = c.x - b.x
const v2y = c.y - b.y
const n = Math.hypot(v1x, v1y) * Math.hypot(v2x, v2y)
if (n === 0) return 180
const cos = Math.min(1, Math.max(-1, (v1x * v2x + v1y * v2y) / n))
return (Math.acos(cos) * 180) / Math.PI
}
/** 坐标是有效数字(真机上画面外的点坐标是 NaN) */
export function isFinitePoint(p: Point | undefined): p is Point {
return !!p && Number.isFinite(p.x) && Number.isFinite(p.y)
}
export function isVisible(pose: Pose, idx: number, min = MIN_POINT_SCORE): boolean {
return (pose.scores[idx] ?? 0) >= min && isFinitePoint(pose.points[idx])
}
/** 肩宽,作为身体尺度基准(对远近不敏感) */
export function shoulderWidth(pose: Pose): number {
return dist(pt(pose, KP.L_SHOULDER), pt(pose, KP.R_SHOULDER))
}
/** 躯干长度:肩中点到髋中点 */
export function torsoLength(pose: Pose): number {
const s = mid(pt(pose, KP.L_SHOULDER), pt(pose, KP.R_SHOULDER))
const h = mid(pt(pose, KP.L_HIP), pt(pose, KP.R_HIP))
return dist(s, h)
}
export type FramingIssue = 'none' | 'no_body' | 'too_close' | 'too_far' | 'off_center' | 'partial'
/**
* 站位检查:是否全身入镜、远近是否合适、是否居中。
* 返回第一个需要纠正的问题。
*/
export function checkFraming(pose: Pose | null): FramingIssue {
if (!pose) return 'no_body'
const missing = FULL_BODY_POINTS.filter((i) => !isVisible(pose, i))
if (missing.length >= FULL_BODY_POINTS.length - 2) return 'no_body'
const top = pose.points[KP.NOSE].y
const bottom = Math.max(pose.points[KP.L_ANKLE].y, pose.points[KP.R_ANKLE].y)
const height = bottom - top
const anklesMissing = !isVisible(pose, KP.L_ANKLE) || !isVisible(pose, KP.R_ANKLE)
if (anklesMissing || bottom > 0.98 || top < 0.02) return 'too_close'
if (missing.length > 0) return 'partial'
if (height < 0.35) return 'too_far'
const cx = (pose.points[KP.L_HIP].x + pose.points[KP.R_HIP].x) / 2
if (cx < 0.25 || cx > 0.75) return 'off_center'
return 'none'
}
export const FRAMING_TIPS: Record<FramingIssue, string> = {
none: '站位很好,保持住',
no_body: '请站到镜头前',
too_close: '请往后退,让头和脚都进入画面',
too_far: '请再往前走一点',
off_center: '请站到画面中间',
partial: '请让全身都进入画面',
}
/**
* 指数平滑:抑制关键点抖动。alpha 越大越跟手,越小越稳。
* 置信度低的点保留上一帧位置,避免骨架乱跳。
* 上一帧坐标无效(NaN)时直接用新坐标重新开始——否则 NaN 会一直传下去,这个点永远是 NaN。
*/
export function smoothPose(prev: Pose | null, next: Pose, alpha = 0.55): Pose {
if (!prev) return next
const points = next.points.map((p, i) => {
const q = prev.points[i]
if (!isFinitePoint(q)) return p
if ((next.scores[i] ?? 0) < MIN_POINT_SCORE || !isFinitePoint(p)) return q
return { x: q.x + (p.x - q.x) * alpha, y: q.y + (p.y - q.y) * alpha }
})
const scores = next.scores.map((s, i) => Math.max(s, (prev.scores[i] ?? 0) * 0.7))
return { points, scores, t: next.t, aspect: next.aspect }
}
type RawPoint = { x: number; y: number } | [number, number]
/**
* 把 VisionKit 的 anchor 转成归一化 Pose。
* 官方示例里 points 已是 0~1 的 {x,y};文档描述为“图像中的坐标”,
* 这里两种都兼容:发现坐标 > 1.5 即按像素处理。
* 画面外的点真机返回 NaN 坐标(置信度却可能不低):坐标置 0、置信度置 0,当作看不见。
*/
export function anchorToPose(
anchor: { points?: RawPoint[]; confidence?: number[]; score?: number },
frameWidth: number,
frameHeight: number,
t: number,
): Pose | null {
const raw = anchor?.points
if (!raw || raw.length < 17) return null
const pts: Point[] = raw.map((p) => (Array.isArray(p) ? { x: p[0], y: p[1] } : { x: p?.x, y: p?.y }))
const isPixel = pts.some((p) => isFinitePoint(p) && (p.x > 1.5 || p.y > 1.5))
const points = pts.map((p) => (!isFinitePoint(p) ? { x: 0, y: 0 } : isPixel ? { x: p.x / frameWidth, y: p.y / frameHeight } : p))
while (points.length < KEYPOINT_COUNT) points.push({ x: 0, y: 0 })
const fallback = typeof anchor.score === 'number' ? anchor.score : 1
const scores = points.map((_, i) => {
if (i >= raw.length || !isFinitePoint(pts[i])) return 0
const c = anchor.confidence?.[i]
return typeof c === 'number' && Number.isFinite(c) ? c : fallback
})
return { points, scores, t, aspect: frameWidth / frameHeight }
}
+328
View File
@@ -0,0 +1,328 @@
/**
* 示范动作骨架动画(正面视角)
*
* 用途:
* 1. 示范节奏:amountAt 决定教练逐帧动画与示范视频的节拍
* 2. 单测与开发者工具模拟:用示范动作合成姿态,验证识别计数逻辑
*
* 坐标系与 VisionKit 一致:x/y 归一化到 [0,1],y 向下;
* 被拍摄者的“左侧”在画面右侧(未镜像的正面视角)。
*/
import { KEYPOINT_COUNT, type Point } from './keypoints.ts'
export type MoveId = 'squat' | 'march' | 'jack' | 'raise'
type ArmParams = {
/** 外展角:0 = 手臂自然下垂,90 = 侧平举,180 = 举过头顶 */
abd: number
/** 前平举程度 0~1(正面看手臂“缩短”) */
fwd: number
}
type LegParams = {
/** 抬膝程度 0~1(1 = 大腿抬平) */
lift: number
/** 脚向外侧迈出的距离 */
step: number
}
type BodyParams = {
/** 下蹲深度 0~1(1 ≈ 大腿与地面平行) */
squat: number
/** 整体上下起伏(踏步时的弹动) */
bounce: number
left: { arm: ArmParams; leg: LegParams }
right: { arm: ArmParams; leg: LegParams }
}
const CX = 0.5
const GROUND = 0.93
const THIGH = 0.2
const SHIN = 0.2
const TORSO = 0.27
const HIP_HALF = 0.058
const SHOULDER_HALF = 0.098
const UPPER_ARM = 0.15
const FOREARM = 0.135
const HAND = 0.045
const rad = (d: number) => (d * Math.PI) / 180
function buildArm(shoulder: Point, side: 1 | -1, arm: ArmParams) {
// 前举时手臂在画面上投影变短
const shrink = 1 - 0.82 * arm.fwd
const a = rad(arm.abd)
const dx = side * Math.sin(a)
const dy = Math.cos(a)
const elbow = { x: shoulder.x + dx * UPPER_ARM * shrink, y: shoulder.y + dy * UPPER_ARM * shrink }
const wrist = { x: elbow.x + dx * FOREARM * shrink, y: elbow.y + dy * FOREARM * shrink }
const hand = { x: wrist.x + dx * HAND * shrink, y: wrist.y + dy * HAND * shrink }
return { elbow, wrist, hand }
}
function buildLeg(hip: Point, side: 1 | -1, leg: LegParams, squat: number) {
const ankleX = hip.x + side * (0.008 + leg.step)
if (leg.lift > 0) {
// 抬膝:大腿向前抬起,正面投影变短;小腿自然下垂
const thighProj = THIGH * Math.cos(rad(leg.lift * 90))
const knee = { x: hip.x + side * 0.004, y: hip.y + thighProj }
const ankle = { x: ankleX, y: knee.y + SHIN * 0.92 }
return { knee, ankle }
}
const ankle = { x: ankleX, y: GROUND }
// 下蹲时膝盖向外打开(膝盖对准脚尖)
const knee = {
x: (hip.x + ankle.x) / 2 + side * 0.035 * squat,
y: hip.y + (ankle.y - hip.y) * (0.5 - 0.06 * squat),
}
return { knee, ankle }
}
export function buildPose(p: BodyParams): Point[] {
const pts: Point[] = new Array(KEYPOINT_COUNT)
// 下蹲:髋部下降,大腿越接近水平,正面投影越短
const hipY = GROUND - SHIN - THIGH * Math.cos(rad(p.squat * 80)) - p.bounce
const lHip = { x: CX + HIP_HALF, y: hipY }
const rHip = { x: CX - HIP_HALF, y: hipY }
// 下蹲时上身前倾,正面看躯干略微变短
const torso = TORSO * (1 - 0.14 * p.squat)
const shoulderY = hipY - torso
const lSh = { x: CX + SHOULDER_HALF, y: shoulderY }
const rSh = { x: CX - SHOULDER_HALF, y: shoulderY }
const noseY = shoulderY - 0.1
pts[0] = { x: CX, y: noseY }
pts[1] = { x: CX + 0.018, y: noseY - 0.018 }
pts[2] = { x: CX - 0.018, y: noseY - 0.018 }
pts[3] = { x: CX + 0.042, y: noseY - 0.008 }
pts[4] = { x: CX - 0.042, y: noseY - 0.008 }
pts[5] = lSh
pts[6] = rSh
pts[11] = lHip
pts[12] = rHip
const la = buildArm(lSh, 1, p.left.arm)
const ra = buildArm(rSh, -1, p.right.arm)
pts[7] = la.elbow
pts[9] = la.wrist
pts[17] = la.hand
pts[8] = ra.elbow
pts[10] = ra.wrist
pts[18] = ra.hand
const ll = buildLeg(lHip, 1, p.left.leg, p.squat)
const rl = buildLeg(rHip, -1, p.right.leg, p.squat)
pts[13] = ll.knee
pts[15] = ll.ankle
pts[14] = rl.knee
pts[16] = rl.ankle
pts[19] = { x: ll.ankle.x + 0.035, y: ll.ankle.y + 0.022 }
pts[21] = { x: ll.ankle.x - 0.008, y: ll.ankle.y + 0.026 }
pts[20] = { x: rl.ankle.x - 0.035, y: rl.ankle.y + 0.022 }
pts[22] = { x: rl.ankle.x + 0.008, y: rl.ankle.y + 0.026 }
return pts
}
const ease = (t: number) => 0.5 - 0.5 * Math.cos(Math.PI * t)
const wrap = (p: number) => ((p % 1) + 1) % 1
/** 平滑的 0→1→0 往返曲线,带顶部/底部停顿 */
function pingPong(phase: number, holdTop = 0.1, holdBottom = 0.1): number {
const moving = (1 - holdTop - holdBottom) / 2
const p = wrap(phase)
if (p < moving) return ease(p / moving)
if (p < moving + holdTop) return 1
if (p < moving * 2 + holdTop) return 1 - ease((p - moving - holdTop) / moving)
return 0
}
export type Side = 'L' | 'R'
/** 动作幅度:0 = 起始姿势,1 = 标准到位;side 用于左右交替的动作 */
export type Amount = { amount: number; side: Side }
export type MotionSpec = {
id: MoveId
/** 一个完整动作周期(秒),即示范节奏 */
cycleSeconds: number
/** 一个周期计几次(踏步、开合左右各算一次) */
repsPerCycle: number
/** 相位 → 动作幅度 */
amountAt: (phase: number) => Amount
/** 动作幅度 → 骨架(镜像模式直接用用户的实际幅度驱动) */
poseFor: (a: Amount) => Point[]
/** 相位 → 骨架 */
poseAt: (phase: number) => Point[]
/** 相位 → 当前阶段提示(示范字幕) */
phaseLabel: (phase: number) => string
}
type SpecInput = Omit<MotionSpec, 'poseAt'>
const spec = (s: SpecInput): MotionSpec => ({ ...s, poseAt: (phase) => s.poseFor(s.amountAt(phase)) })
/** 左右交替动作:前半周期左侧,后半周期右侧 */
function alternating(phase: number, shape: (half: number) => number): Amount {
const p = wrap(phase)
const side: Side = p < 0.5 ? 'L' : 'R'
const half = p < 0.5 ? p / 0.5 : (p - 0.5) / 0.5
return { amount: shape(half), side }
}
export const MOTIONS: Record<MoveId, MotionSpec> = {
squat: spec({
id: 'squat',
cycleSeconds: 3.2,
repsPerCycle: 1,
amountAt: (phase) => ({ amount: pingPong(phase, 0.12, 0.16), side: 'L' }),
poseFor({ amount: d }) {
const arm = { abd: 8 + 4 * d, fwd: d }
return buildPose({
squat: d * 0.95,
bounce: 0,
left: { arm, leg: { lift: 0, step: 0.045 } },
right: { arm, leg: { lift: 0, step: 0.045 } },
})
},
phaseLabel(phase) {
const p = wrap(phase)
if (p < 0.36) return '吸气 · 屁股向后坐'
if (p < 0.48) return '停一下 · 膝盖对准脚尖'
if (p < 0.84) return '呼气 · 脚跟发力站起'
return '站直 · 准备下一次'
},
}),
march: spec({
id: 'march',
cycleSeconds: 1.6,
repsPerCycle: 2,
amountAt: (phase) => alternating(phase, (h) => Math.sin(Math.PI * h)),
poseFor({ amount: a, side }) {
// 抬起一侧膝盖,对侧手臂前摆
const l = side === 'L' ? a : 0
const r = side === 'R' ? a : 0
return buildPose({
squat: 0,
bounce: 0.012 * a,
left: { arm: { abd: 10, fwd: 0.75 * r }, leg: { lift: 0.85 * l, step: 0 } },
right: { arm: { abd: 10, fwd: 0.75 * l }, leg: { lift: 0.85 * r, step: 0 } },
})
},
phaseLabel(phase) {
return wrap(phase) < 0.5 ? '抬左膝 · 右手前摆' : '抬右膝 · 左手前摆'
},
}),
jack: spec({
id: 'jack',
cycleSeconds: 2.4,
repsPerCycle: 2,
amountAt: (phase) => alternating(phase, (h) => pingPong(h, 0.14, 0.14)),
poseFor({ amount: o, side }) {
// 低冲击版:不跳,左右交替向侧面迈步,同时双臂上举
const arm = { abd: 12 + 158 * o, fwd: 0 }
return buildPose({
squat: 0.06 * o,
bounce: 0,
left: { arm, leg: { lift: 0, step: side === 'L' ? 0.11 * o : 0 } },
right: { arm, leg: { lift: 0, step: side === 'R' ? 0.11 * o : 0 } },
})
},
phaseLabel(phase) {
return wrap(phase) < 0.5 ? '左脚向外迈 · 双手举过头' : '右脚向外迈 · 双手举过头'
},
}),
raise: spec({
id: 'raise',
cycleSeconds: 3,
repsPerCycle: 1,
amountAt: (phase) => ({ amount: pingPong(phase, 0.14, 0.14), side: 'L' }),
poseFor({ amount: o }) {
const arm = { abd: 10 + 78 * o, fwd: 0 }
return buildPose({
squat: 0,
bounce: 0,
left: { arm, leg: { lift: 0, step: 0.035 } },
right: { arm, leg: { lift: 0, step: 0.035 } },
})
},
phaseLabel(phase) {
const p = wrap(phase)
if (p < 0.36) return '呼气 · 手臂向两侧抬起'
if (p < 0.5) return '停一下 · 与肩同高'
if (p < 0.86) return '吸气 · 慢慢放下'
return '放松 · 肩膀下沉'
},
}),
}
/**
* 常见错误动作(示范视频“错误 vs 正确”对比用,也用于单测验证纠错提示)
*/
export const MISTAKE_POSES: Record<MoveId, (phase: number) => Point[]> = {
// 膝盖内扣:下蹲时膝盖向身体中线靠拢
squat(phase) {
const d = pingPong(phase, 0.12, 0.16)
const pts = MOTIONS.squat.poseAt(phase)
const pull = 0.075 * d
pts[13] = { x: pts[13].x - pull, y: pts[13].y }
pts[14] = { x: pts[14].x + pull, y: pts[14].y }
return pts
},
// 膝盖抬得太低
march(phase) {
const p = wrap(phase)
const l = p < 0.5 ? Math.sin(Math.PI * (p / 0.5)) : 0
const r = p >= 0.5 ? Math.sin(Math.PI * ((p - 0.5) / 0.5)) : 0
return buildPose({
squat: 0,
bounce: 0.006 * Math.max(l, r),
left: { arm: { abd: 8, fwd: 0.2 * r }, leg: { lift: 0.32 * l, step: 0 } },
right: { arm: { abd: 8, fwd: 0.2 * l }, leg: { lift: 0.32 * r, step: 0 } },
})
},
// 手臂只抬到胸口
jack(phase) {
const p = wrap(phase)
const half = p < 0.5 ? p / 0.5 : (p - 0.5) / 0.5
const o = pingPong(half, 0.14, 0.14)
const arm = { abd: 12 + 70 * o, fwd: 0 }
const leftOut = p < 0.5
return buildPose({
squat: 0,
bounce: 0,
left: { arm, leg: { lift: 0, step: leftOut ? 0.05 * o : 0 } },
right: { arm, leg: { lift: 0, step: leftOut ? 0 : 0.05 * o } },
})
},
// 耸肩且手臂抬得过高
raise(phase) {
const o = pingPong(phase, 0.14, 0.14)
const arm = { abd: 10 + 125 * o, fwd: 0 }
const pts = buildPose({
squat: 0,
bounce: 0,
left: { arm, leg: { lift: 0, step: 0.035 } },
right: { arm, leg: { lift: 0, step: 0.035 } },
})
const shrug = 0.03 * o
for (const i of [5, 6, 7, 8, 9, 10, 17, 18]) pts[i] = { x: pts[i].x, y: pts[i].y - shrug }
return pts
},
}
/**
* 示范动作“完成一次”的检测:幅度到过顶点、再回落到起始附近,算完成一次。
* 领练模式按教练动画计数,报数和画面同步(按时钟计数会比教练做完晚半拍)。
*/
export function createRepEdge(peak = 0.9, exit = 0.25) {
let peaked = false
return (amount: number): boolean => {
if (amount >= peak) {
peaked = true
} else if (peaked && amount <= exit) {
peaked = false
return true
}
return false
}
}
@@ -0,0 +1,51 @@
/**
* 开发版真机排查(默认全部关闭,编译时用环境变量打开,发行版始终不生效):
* - VITE_POSE_DIAG=1:跟练页显示识别诊断面板,console 每 2 秒输出识别摘要
* - VITE_POSE_LOG_URL=http://电脑局域网IP:端口/log:把识别数据批量发到电脑上的日志服务,边练边看。
* 地址不写进代码;需要项目关闭合法域名校验(manifest 里 urlCheck: false)
*/
const LOG_URL: string = ((import.meta as any).env?.VITE_POSE_LOG_URL as string | undefined) || ''
const FLUSH_MS = 1000
/** 断网时最多攒这么多条,超出丢最旧的 */
const MAX_QUEUE = 600
const IS_DEV = process.env.NODE_ENV === 'development'
export const remoteLogEnabled = Boolean(LOG_URL) && IS_DEV
/** 识别诊断面板和 console 摘要 */
export const poseDiagEnabled = IS_DEV && Boolean((import.meta as any).env?.VITE_POSE_DIAG)
let queue: Record<string, unknown>[] = []
let timer: ReturnType<typeof setTimeout> | null = null
let session = ''
let failWarned = false
/** 开始一次新的跟练:之后的日志带上同一个会话编号 */
export function rlogSession() {
session = String(Date.now())
}
export function rlog(type: string, data: Record<string, unknown> = {}) {
if (!remoteLogEnabled) return
queue.push({ t: Date.now(), type, ...data })
if (queue.length > MAX_QUEUE) queue = queue.slice(-MAX_QUEUE)
if (!timer) timer = setTimeout(flush, FLUSH_MS)
}
function flush() {
timer = null
if (!queue.length) return
const entries = queue
queue = []
uni.request({
url: LOG_URL,
method: 'POST',
data: { session, entries },
timeout: 3000,
fail: (e) => {
if (failWarned) return
failWarned = true
console.warn('[跟练日志] 发送失败,检查手机和电脑是否在同一网络、日志服务是否启动', LOG_URL, e)
},
})
}
+232
View File
@@ -0,0 +1,232 @@
/**
* 一次跟练的状态机(纯逻辑,可单测)
*
* framing ──站好并保持 1.5s──▶ countdown ──3s──▶ training ──达到目标──▶ done
* │ ▲
* 离开画面 >1.5s ▼ │ 回到画面
* lost
* 任意训练阶段可 pause / resume,finish 提前结束。
*/
import { checkFraming, type FramingIssue, type Pose } from './keypoints.ts'
import { createCounter, type CoachEvent, type Grade, type RepCounter } from './counters.ts'
import type { Amount, MoveId } from './motions.ts'
export type SessionPhase = 'framing' | 'countdown' | 'training' | 'lost' | 'paused' | 'done'
export type SessionEvent =
| { type: 'phase'; phase: SessionPhase }
| { type: 'countdown'; n: number }
| { type: 'combo'; combo: number }
| CoachEvent
export type RepRecord = { score: number; grade: Grade; tip?: string }
export const FRAMING_HOLD_MS = 1500
export const COUNTDOWN_MS = 3000
export const LOST_AFTER_MS = 1500
/** 连击:连续得分不低于该值 */
export const COMBO_SCORE = 80
const COMBO_MILESTONES = [3, 5, 10, 15, 20, 30, 40, 50]
export class TrainingSession {
readonly move: MoveId
readonly targetReps: number
readonly counter: RepCounter
phase: SessionPhase = 'framing'
framingIssue: FramingIssue = 'no_body'
/** 站位保持进度 0~1 */
holdProgress = 0
/** 倒计时剩余整数秒 */
countdown = 0
combo = 0
bestCombo = 0
reps: RepRecord[] = []
/** 有效训练时长(不含站位、倒计时、离开画面与暂停) */
activeMs = 0
/** 领练模式(无识别)按节奏累计的次数 */
private guidedReps = 0
private readonly detectLost: boolean
private okSince = 0
private countdownStart = 0
private lastSeen = 0
private lastTick = 0
private beforePause: SessionPhase = 'training'
constructor(move: MoveId, targetReps: number, opts: { detectLost?: boolean } = {}) {
this.move = move
this.targetReps = targetReps
this.counter = createCounter(move)
this.detectLost = opts.detectLost ?? true
}
get count() {
return this.counter.count + this.guidedReps
}
get live(): Amount {
return this.counter.live
}
get progress() {
return Math.min(1, this.count / this.targetReps)
}
/** 每收到一帧检测结果调用(pose 为 null 表示画面里没人) */
feed(pose: Pose | null, now: number): SessionEvent[] {
const events = this.tick(now)
const issue = checkFraming(pose)
this.framingIssue = issue
if (issue !== 'no_body') this.lastSeen = now
switch (this.phase) {
case 'framing':
if (issue === 'none') {
if (!this.okSince) this.okSince = now
this.holdProgress = Math.min(1, (now - this.okSince) / FRAMING_HOLD_MS)
if (now - this.okSince >= FRAMING_HOLD_MS) {
this.countdownStart = now
this.countdown = 3
events.push(...this.setPhase('countdown'), { type: 'countdown', n: 3 })
}
} else {
this.okSince = 0
this.holdProgress = 0
}
break
case 'training':
if (pose && issue !== 'no_body') {
for (const e of this.counter.update(pose)) events.push(...this.onCoach(e))
}
break
case 'lost':
if (issue === 'none' || issue === 'partial') events.push(...this.setPhase('training'))
break
}
return events
}
/** 驱动计时与倒计时;没有新帧时也应定时调用 */
tick(now: number): SessionEvent[] {
const events: SessionEvent[] = []
const dt = this.lastTick ? now - this.lastTick : 0
this.lastTick = now
if (this.phase === 'training') {
this.activeMs += dt
if (this.detectLost && this.lastSeen && now - this.lastSeen > LOST_AFTER_MS) events.push(...this.setPhase('lost'))
} else if (this.phase === 'countdown') {
const left = Math.ceil((COUNTDOWN_MS - (now - this.countdownStart)) / 1000)
if (left <= 0) {
this.lastSeen = now
events.push(...this.setPhase('training'))
} else if (left !== this.countdown) {
this.countdown = left
events.push({ type: 'countdown', n: left })
}
}
return events
}
/** 跳过站位直接倒计时(领练模式) */
startCountdown(now: number): SessionEvent[] {
if (this.phase !== 'framing') return []
this.countdownStart = now
this.countdown = 3
this.lastTick = now
return [...this.setPhase('countdown'), { type: 'countdown', n: 3 }]
}
/** 领练模式:按示范节奏计一次(不评分,不计入动作质量) */
addGuidedRep(): SessionEvent[] {
if (this.phase !== 'training') return []
this.guidedReps += 1
const out: SessionEvent[] = [{ type: 'rep', count: this.count, score: 0, grade: 'ok' }]
if (this.count >= this.targetReps) out.push(...this.setPhase('done'))
return out
}
pause(): SessionEvent[] {
if (this.phase === 'paused' || this.phase === 'done') return []
this.beforePause = this.phase === 'lost' ? 'training' : this.phase
return this.setPhase('paused')
}
resume(now: number): SessionEvent[] {
if (this.phase !== 'paused') return []
this.lastTick = now
this.lastSeen = now
// 倒计时中被暂停,恢复后重新站位
const next = this.beforePause === 'countdown' ? 'framing' : this.beforePause
this.okSince = 0
return this.setPhase(next)
}
finish(): SessionEvent[] {
return this.phase === 'done' ? [] : this.setPhase('done')
}
private setPhase(p: SessionPhase): SessionEvent[] {
if (this.phase === p) return []
this.phase = p
return [{ type: 'phase', phase: p }]
}
private onCoach(e: CoachEvent): SessionEvent[] {
if (e.type !== 'rep') return [e]
const out: SessionEvent[] = [e]
this.reps.push({ score: e.score, grade: e.grade, tip: e.tip })
if (e.score >= COMBO_SCORE && !e.joints) {
this.combo += 1
this.bestCombo = Math.max(this.bestCombo, this.combo)
if (COMBO_MILESTONES.includes(this.combo)) out.push({ type: 'combo', combo: this.combo })
} else {
this.combo = 0
}
if (this.count >= this.targetReps) out.push(...this.setPhase('done'))
return out
}
}
export type SessionSummary = {
move: MoveId
reps: number
targetReps: number
avgScore: number
bestCombo: number
durationSec: number
kcal: number
stars: 1 | 2 | 3
scores: number[]
/** 出现最多的纠正提示,作为下次训练建议;没有纠正时为鼓励语 */
advice: string
/** advice 是纠正建议(tip)还是鼓励(praise) */
adviceKind: 'tip' | 'praise'
}
/** 汇总结果;kcal = 净 MET × 体重 × 小时 */
export function summarize(s: TrainingSession, netMet: number, weightKg = 60): SessionSummary {
const scores = s.reps.map((r) => r.score)
const avg = scores.length ? Math.round(scores.reduce((a, b) => a + b, 0) / scores.length) : 0
const durationSec = Math.round(s.activeMs / 1000)
const kcal = Math.round(netMet * weightKg * (durationSec / 3600) * 10) / 10
const tips = new Map<string, number>()
for (const r of s.reps) if (r.tip) tips.set(r.tip, (tips.get(r.tip) ?? 0) + 1)
const top = [...tips.entries()].sort((a, b) => b[1] - a[1])[0]?.[0]
const done = s.count >= s.targetReps
const stars: 1 | 2 | 3 = done && avg >= 88 ? 3 : done && avg >= 75 ? 2 : 1
return {
move: s.move,
reps: s.count,
targetReps: s.targetReps,
avgScore: avg,
bestCombo: s.bestCombo,
durationSec,
kcal,
stars,
scores,
advice: top ?? (avg >= 90 ? '动作非常标准,继续保持!' : '保持节奏,每次都做到位'),
adviceKind: top ? 'tip' : 'praise',
}
}
+64
View File
@@ -0,0 +1,64 @@
/**
* 训练背景音乐(页面用)
*
* 播放控制在 bgm.ts;这里负责创建播放器、把开关记在本地。
* 音乐地址为空或加载失败时 available 为 false,页面隐藏开关,训练照常进行。
*/
import { onUnmounted, ref } from 'vue'
import { createBgm, type BgmAudio, type BgmState } from './bgm'
const STORAGE_KEY = 'pose_coach_bgm_enabled'
/** 教练语音音量是 1,音乐压在它下面约 9dB,报数、口令都听得清 */
const BGM_VOLUME = 0.35
export function useBgm(url: string) {
const enabled = ref<boolean>(readEnabled())
const available = ref(Boolean(url))
const player = createBgm({
url,
volume: BGM_VOLUME,
create: createAudio,
onUnavailable: (reason) => {
available.value = false
if (process.env.NODE_ENV === 'development') console.warn('[背景音乐] 加载失败,本次不播放:', reason, url)
},
})
player.setEnabled(enabled.value)
function toggle() {
enabled.value = !enabled.value
try {
uni.setStorageSync(STORAGE_KEY, enabled.value)
} catch (e) {}
player.setEnabled(enabled.value)
}
onUnmounted(() => player.destroy())
return {
enabled,
available,
toggle,
prepare: () => player.prepare(),
setState: (s: BgmState) => player.setState(s),
setDuck: (ratio: number) => player.setDuck(ratio),
hush: () => player.hush(),
}
}
function readEnabled() {
try {
const v = uni.getStorageSync(STORAGE_KEY)
return v === '' ? true : Boolean(v)
} catch (e) {
return true
}
}
/**
* 长音频用默认的系统播放器边下边播(教练语音用的 WebAudio 实现要把整段解码进内存)。
* 静音键:跟练页进入时统一设置了不跟随静音键(setInnerAudioOption),这里不用单独设。
*/
function createAudio(): BgmAudio {
return uni.createInnerAudioContext() as unknown as BgmAudio
}
@@ -0,0 +1,223 @@
/**
* 教练语音播报
*
* 报数要和动作同步,关键是“即时出声”:
* - 语音由 WechatSI 同声传译插件合成(需要联网),进入页面就把报数、口令、纠错预合成好;
* - 每句合成后立刻建好一个 WebAudio 驱动的播放器(useWebAudioImplement,短音频延迟低),
* 播报时从头播放,不再临时创建播放器、下载音频;
* - 报数没准备好就跳过这一次,不在几百毫秒后“补报”,免得越报越落后。
* 优先级:high(纠错、倒计时)> count(报数)> low(鼓励)。
* high 打断一切;count 打断 count / low,不打断 high;low 只在空闲时播。
* 其它端:H5 用 speechSynthesis,都不支持时静默。
* speaking 是正在播的语句的优先级(没在说话时为 null),背景音乐据此压低音量。
*/
import { ref, onUnmounted } from 'vue'
export type Priority = 'low' | 'count' | 'high'
const STORAGE_KEY = 'pose_coach_voice_enabled'
/** 没预合成的 high 语句:合成耗时超过这个值就不播了,避免提示和动作对不上 */
const HIGH_STALE_MS = 1500
const RANK: Record<Priority, number> = { low: 0, count: 1, high: 2 }
/** 单句都很短:超过这个时间还没收到播完回调(个别机型不触发),视为已播完 */
const MAX_UTTERANCE_MS = 4000
type Player = UniApp.InnerAudioContext
export function useCoachVoice() {
const enabled = ref<boolean>(readEnabled())
const players = new Map<string, Player>()
const inflight = new Map<string, Promise<Player | null>>()
let current: { player: Player; priority: Priority; at: number } | null = null
const speaking = ref<Priority | null>(null)
let utterance = 0
let speakingTimer: ReturnType<typeof setTimeout> | null = null
function readEnabled() {
try {
const v = uni.getStorageSync(STORAGE_KEY)
return v === '' ? true : Boolean(v)
} catch (e) {
return true
}
}
function toggle() {
enabled.value = !enabled.value
try {
uni.setStorageSync(STORAGE_KEY, enabled.value)
} catch (e) {}
if (!enabled.value) stop()
}
/** 标记开始 / 结束说话,返回这句话的编号;个别机型不触发播完回调,最长按 MAX_UTTERANCE_MS 算 */
function markSpeaking(priority: Priority | null) {
utterance += 1
const id = utterance
speaking.value = priority
if (speakingTimer) clearTimeout(speakingTimer)
speakingTimer = priority ? setTimeout(() => endSpeaking(id), MAX_UTTERANCE_MS) : null
return id
}
function endSpeaking(id: number) {
if (id === utterance) markSpeaking(null)
}
/** 合成一句语音,返回音频地址 */
function synth(text: string): Promise<string | null> {
return new Promise((resolve) => {
// #ifdef MP-WEIXIN
try {
// eslint-disable-next-line no-undef
const plugin = requirePlugin('WechatSI')
plugin.textToSpeech({
lang: 'zh_CN',
tts: true,
content: text,
success: (res: { filename?: string }) => resolve(res?.filename || null),
fail: () => resolve(null),
})
} catch (e) {
resolve(null)
}
// #endif
// #ifndef MP-WEIXIN
resolve(null)
// #endif
})
}
function createPlayer(src: string): Player {
let p: Player
// #ifdef MP-WEIXIN
p = (wx as any).createInnerAudioContext({ useWebAudioImplement: true })
// #endif
// #ifndef MP-WEIXIN
p = uni.createInnerAudioContext()
// #endif
p.src = src
;(p as any).obeyMuteSwitch = false
p.onEnded(() => {
if (current?.player !== p) return
current = null
markSpeaking(null)
})
return p
}
/** 合成并建好播放器(同一句只做一次) */
function prepare(text: string): Promise<Player | null> {
const ready = players.get(text)
if (ready) return Promise.resolve(ready)
const running = inflight.get(text)
if (running) return running
const p = synth(text)
.then((src) => {
if (!src) return null
const player = createPlayer(src)
player.onError(() => {
// 音频地址过期或加载失败:丢掉,下次重新合成
if (players.get(text) === player) players.delete(text)
if (current?.player === player) {
current = null
markSpeaking(null)
}
})
players.set(text, player)
return player
})
.finally(() => inflight.delete(text))
inflight.set(text, p)
return p
}
/** 预合成(并发 2),不阻塞页面 */
async function preload(texts: string[]) {
const queue = [...new Set(texts)].filter((t) => t && !players.has(t))
const worker = async () => {
while (queue.length) {
const t = queue.shift()
if (t) await prepare(t)
}
}
await Promise.all([worker(), worker()])
}
function canPlay(priority: Priority) {
if (current && Date.now() - current.at > MAX_UTTERANCE_MS) current = null
if (!current) return true
if (priority === 'low') return false
return RANK[priority] >= RANK[current.priority]
}
function playNow(player: Player, priority: Priority) {
if (current && current.player !== player) {
try {
current.player.stop()
} catch (e) {}
}
try {
player.seek(0)
} catch (e) {}
player.play()
current = { player, priority, at: Date.now() }
markSpeaking(priority)
}
async function speak(text: string, priority: Priority = 'low') {
if (!enabled.value || !text || !canPlay(priority)) return
// #ifdef H5
speakWeb(text, priority)
return
// #endif
const ready = players.get(text)
if (ready) return playNow(ready, priority)
if (priority !== 'high') {
// 报数、鼓励没准备好:这次跳过,合成好留给下次
void prepare(text)
return
}
const t0 = Date.now()
const player = await prepare(text)
if (player && enabled.value && Date.now() - t0 <= HIGH_STALE_MS && canPlay(priority)) playNow(player, priority)
}
/** H5:浏览器自带语音合成 */
function speakWeb(text: string, priority: Priority) {
try {
const synthApi = (window as any).speechSynthesis
if (!synthApi) return
if (priority !== 'low') synthApi.cancel()
const u = new (window as any).SpeechSynthesisUtterance(text)
u.lang = 'zh-CN'
u.rate = 1.1
const id = markSpeaking(priority)
u.onend = () => endSpeaking(id)
synthApi.speak(u)
} catch (e) {}
}
function stop() {
if (current) {
try {
current.player.stop()
} catch (e) {}
current = null
}
markSpeaking(null)
}
onUnmounted(() => {
stop()
if (speakingTimer) clearTimeout(speakingTimer)
for (const p of players.values()) {
try {
p.destroy()
} catch (e) {}
}
players.clear()
})
return { enabled, toggle, speak, preload, stop, speaking }
}
@@ -0,0 +1,322 @@
/**
* 摄像头取帧 + VisionKit 人体关键点检测
*
* 方案:<camera> 负责预览(原生、流畅),CameraContext.onCameraFrame 拿 RGBA 帧,
* 节流后交给 VKSession.detectBody(mode 2,手动传图),结果通过 updateAnchors 回调。
* 这样不需要自己用 WebGL 渲染相机画面;<camera> 放在哪个组件里都可以(一个页面只有一个摄像头)。
*
* 要求:微信基础库 >= 2.28.0;非微信端或不支持的机型会返回 unsupported。
*/
import { ref, shallowRef, onUnmounted } from 'vue'
import { anchorToPose, smoothPose, type Pose } from './keypoints.ts'
import { poseDiagEnabled, rlog } from './remoteLog.ts'
export type CameraStatus = 'idle' | 'starting' | 'running' | 'unsupported' | 'denied' | 'error'
/** 检测间隔:约 10fps,兼顾流畅与发热 */
const DETECT_INTERVAL_MS = 90
/** 检测请求超时:没有回调视为画面里没人 */
const DETECT_TIMEOUT_MS = 600
/** 连续多久没检测到人,清空姿态 */
const LOST_AFTER_MS = 800
/** 诊断面板刷新间隔;console 摘要间隔 */
const DIAG_REFRESH_MS = 500
const DIAG_LOG_MS = 2000
type FrameInfo = { width: number; height: number }
type AnchorSource = 'add' | 'update' | 'remove' | 'timeout'
/** 真机排查用:识别引擎的事件计数和最近一次识别到人的原始数据摘要(开发版跟练页显示) */
export type PoseDiag = {
events: Record<AnchorSource | 'error', number>
/** detectBody 发出到收到结果的耗时 */
latencyMs: number
/** 最近一次识别到人的原始数据摘要 */
raw: string
}
export function usePoseCamera(onPose?: (pose: Pose | null) => void) {
const status = ref<CameraStatus>('idle')
const errorText = ref('')
const pose = shallowRef<Pose | null>(null)
const frame = ref<FrameInfo>({ width: 480, height: 640 })
/** 实际检测帧率,调试用 */
const fps = ref(0)
const diag = shallowRef<PoseDiag>(emptyDiag())
const events = { ...emptyDiag().events }
let latencyMs = 0
let lastRaw = ''
let firstAnchorLogged = false
let lastDiagAt = 0
let lastLogAt = 0
let session: any = null
let listener: any = null
let pending = false
let pendingTimer: ReturnType<typeof setTimeout> | null = null
let lastSent = 0
let lastSeen = 0
let sentFrame: FrameInfo = { width: 0, height: 0 }
let fpsCount = 0
let fpsTimer: ReturnType<typeof setInterval> | null = null
function emit(next: Pose | null) {
pose.value = next
onPose?.(next)
}
function clearPending() {
pending = false
if (pendingTimer) {
clearTimeout(pendingTimer)
pendingTimer = null
}
}
function handleAnchors(anchors: any[], source: AnchorSource) {
// 已经停止(训练结束、切到领练)后迟到的识别结果直接丢弃
if (!session) return
const now = Date.now()
if (pending && (source === 'add' || source === 'update')) latencyMs = now - lastSent
events[source] += 1
clearPending()
fpsCount += 1
// 画面里多人时取检测框最大的那个
const best = (anchors || []).slice().sort((a, b) => area(b) - area(a))[0]
rlog('det', {
src: source,
lat: source === 'add' || source === 'update' ? latencyMs : undefined,
n: anchors?.length ?? 0,
// 原始坐标不是有效数字的点(画面外的点真机返回 NaN)
nan: best ? nanPoints(best) : undefined,
})
if (best) recordRaw(best)
const next = best ? anchorToPose(best, sentFrame.width, sentFrame.height, now) : null
if (next) {
lastSeen = now
emit(smoothPose(pose.value, next))
} else if (now - lastSeen > LOST_AFTER_MS) {
emit(null)
}
refreshDiag(now)
}
/** 记下原始 anchor 的摘要;第一次识别到人时把完整原始数据打到 console,便于核对字段格式 */
function recordRaw(anchor: any) {
if (poseDiagEnabled && !firstAnchorLogged) {
firstAnchorLogged = true
// anchor 是原生对象,字段不可枚举,直接 JSON.stringify 得到 {};逐个取出再序列化,NaN 显式写出来
const json = JSON.stringify(plainAnchor(anchor))
console.log('[跟练诊断] 首个原始 anchor', `帧 ${sentFrame.width}x${sentFrame.height}`, json.slice(0, 1500))
rlog('first_anchor', { frame: `${sentFrame.width}x${sentFrame.height}`, anchor: json.slice(0, 4000) })
}
lastRaw = summarizeAnchor(anchor)
}
function refreshDiag(now: number) {
if (now - lastDiagAt < DIAG_REFRESH_MS) return
lastDiagAt = now
diag.value = { events: { ...events }, latencyMs, raw: lastRaw }
if (poseDiagEnabled && now - lastLogAt >= DIAG_LOG_MS) {
lastLogAt = now
const e = events
console.log(
`[跟练诊断] 帧 ${frame.value.width}x${frame.value.height} 延迟 ${latencyMs}ms 事件 add${e.add} upd${e.update} rm${e.remove} 超时${e.timeout} 错${e.error} | ${lastRaw || '尚未识别到人'}`,
)
}
}
function area(a: any) {
const s = a?.size
if (!s) return 0
return Array.isArray(s) ? s[0] * s[1] : (s.width || 0) * (s.height || 0)
}
/** 相机组件 initdone 后调用 */
function start() {
if (status.value === 'running' || status.value === 'starting') return
status.value = 'starting'
Object.assign(events, emptyDiag().events)
latencyMs = 0
lastRaw = ''
firstAnchorLogged = false
diag.value = emptyDiag()
rlog('camera_start', deviceInfo())
// #ifdef MP-WEIXIN
startVisionKit()
// #endif
// #ifndef MP-WEIXIN
status.value = 'unsupported'
// #endif
}
function startVisionKit() {
try {
const w: any = wx
if (typeof w.createVKSession !== 'function' || (w.isVKSupport && !w.isVKSupport('v1'))) {
status.value = 'unsupported'
rlog('vk_unsupported', { hasCreate: typeof w.createVKSession === 'function' })
return
}
session = w.createVKSession({ track: { body: { mode: 2 } }, version: 'v1' })
// 连续帧(sourceType 0)会跟踪人体:首次识别到人、丢失后重新出现走 addAnchors,之后每帧走 updateAnchors(同官方示例)
session.on('addAnchors', (anchors: any[]) => handleAnchors(anchors, 'add'))
session.on('updateAnchors', (anchors: any[]) => handleAnchors(anchors, 'update'))
session.on('removeAnchors', () => handleAnchors([], 'remove'))
session.start((err: any) => {
if (err) {
status.value = 'unsupported'
errorText.value = String(err?.errMsg || err)
rlog('vk_start_error', { err: errorText.value })
return
}
rlog('vk_started')
startFrames()
})
} catch (e: any) {
status.value = 'unsupported'
errorText.value = String(e?.message || e)
rlog('vk_exception', { err: errorText.value })
}
}
function startFrames() {
const cam: any = uni.createCameraContext()
if (!cam?.onCameraFrame) {
status.value = 'unsupported'
rlog('no_camera_frame_api')
return
}
listener = cam.onCameraFrame((f: { data: ArrayBuffer; width: number; height: number }) => {
const now = Date.now()
if (pending || now - lastSent < DETECT_INTERVAL_MS || !session) return
if (frame.value.width !== f.width || frame.value.height !== f.height) {
frame.value = { width: f.width, height: f.height }
rlog('frame_size', { w: f.width, h: f.height, bytes: f.data?.byteLength })
}
pending = true
lastSent = now
sentFrame = { width: f.width, height: f.height }
pendingTimer = setTimeout(() => handleAnchors([], 'timeout'), DETECT_TIMEOUT_MS)
try {
session.detectBody({
frameBuffer: f.data,
width: f.width,
height: f.height,
scoreThreshold: 0.5,
// 0 表示输入来自视频连续帧,算法会做帧间跟踪
sourceType: 0,
})
} catch (e) {
events.error += 1
if (poseDiagEnabled) console.warn('[跟练诊断] detectBody 异常', e)
rlog('detect_err', { err: String((e as any)?.message || e) })
clearPending()
}
})
listener.start({
success: () => {
status.value = 'running'
rlog('frames_started')
fpsTimer = setInterval(() => {
fps.value = fpsCount
fpsCount = 0
}, 1000)
},
fail: (e: any) => {
status.value = 'error'
errorText.value = String(e?.errMsg || e)
rlog('frames_fail', { err: errorText.value })
},
})
}
function stop() {
clearPending()
try {
listener?.stop?.()
} catch (e) {}
listener = null
const oldSession = session
session = null
rlog('camera_stop')
// 停止可能发生在识别引擎自己的回调里(第 N 次计数 → 结束 → 关摄像头):在回调里同步销毁引擎,iOS 真机会卡死。
// 先断开引用(之后迟到的回调全部丢弃),等当前回调返回后再销毁
setTimeout(() => {
try {
oldSession?.stop?.()
oldSession?.destroy?.()
} catch (e) {}
}, 0)
if (fpsTimer) clearInterval(fpsTimer)
fpsTimer = null
if (status.value === 'running' || status.value === 'starting') status.value = 'idle'
pose.value = null
}
/** <camera @error>:通常是用户拒绝了摄像头权限 */
function onCameraError(e: any) {
status.value = 'denied'
errorText.value = String(e?.detail?.errMsg || '')
}
onUnmounted(stop)
return { status, errorText, pose, frame, fps, diag, start, stop, onCameraError }
}
const num = (v: unknown) => (typeof v === 'number' ? (Number.isFinite(v) ? +v.toFixed(4) : String(v)) : v)
const rawXY = (p: any) => (Array.isArray(p) ? [p[0], p[1]] : [p?.x, p?.y])
/** 原始坐标不是有效数字的点的序号,如 "13,14,15,16" */
function nanPoints(anchor: any): string {
const pts: any[] = Array.isArray(anchor?.points) ? anchor.points : []
return pts
.map((p, i) => (rawXY(p).every((v) => typeof v === 'number' && Number.isFinite(v)) ? -1 : i))
.filter((i) => i >= 0)
.join(',')
}
function plainAnchor(a: any) {
return {
keys: Object.keys(a ?? {}),
score: num(a?.score),
origin: a?.origin ? [num(a.origin.x), num(a.origin.y)] : a?.origin,
size: a?.size ? [num(a.size.width), num(a.size.height)] : a?.size,
points: Array.isArray(a?.points) ? a.points.map((p: any) => rawXY(p).map(num)) : typeof a?.points,
confidence: Array.isArray(a?.confidence) ? a.confidence.map(num) : typeof a?.confidence,
}
}
/** 机型与微信版本,排查机型差异用 */
function deviceInfo() {
try {
const s: any = uni.getSystemInfoSync()
return { brand: s.brand, model: s.model, system: s.system, platform: s.platform, wx: s.version, sdk: s.SDKVersion }
} catch (e) {
return {}
}
}
function emptyDiag(): PoseDiag {
return { events: { add: 0, update: 0, remove: 0, timeout: 0, error: 0 }, latencyMs: 0, raw: '' }
}
const f2 = (v: unknown) => (typeof v === 'number' ? v.toFixed(2) : String(v))
/** 原始 anchor 摘要:点数、坐标是比例还是像素、置信度范围,以及鼻子 / 髋 / 踝的原始坐标 */
function summarizeAnchor(a: any): string {
const pts: any[] = Array.isArray(a?.points) ? a.points : []
const xy = (i: number) => {
const p = pts[i]
if (!p) return '-'
return Array.isArray(p) ? `${f2(p[0])},${f2(p[1])}` : `${f2(p.x)},${f2(p.y)}`
}
const flat = pts.flatMap((p) => (Array.isArray(p) ? p : [p?.x, p?.y])).filter((v) => typeof v === 'number')
const unit = flat.some((v) => v > 1.5) ? '像素' : '比例'
const conf: number[] = Array.isArray(a?.confidence) ? a.confidence : []
const confText = conf.length ? `${f2(Math.min(...conf))}~${f2(Math.max(...conf))}` : '无'
return `${pts.length}点 ${unit} 框分${f2(a?.score)} 点置信${confText} 鼻${xy(0)} 左髋${xy(11)} 右髋${xy(12)} 左踝${xy(15)} 右踝${xy(16)}`
}
+26
View File
@@ -0,0 +1,26 @@
/**
* 摄像头帧坐标 → 屏幕坐标
*
* 识别出的关键点是相对摄像头帧归一化的 [0,1] 坐标;<camera> 预览按 aspectFill 铺满显示区域(裁掉多出的部分),
* 前置摄像头的预览是镜像的。纠正动作时在画面上标出关节,需要用它换算位置。
*/
import type { Point } from './keypoints.ts'
export type Viewport = {
/** 显示区域尺寸(px) */
width: number
height: number
/** 摄像头帧尺寸 */
frameWidth: number
frameHeight: number
/** 预览是否镜像 */
mirror: boolean
}
export function toScreen(p: Point, vp: Viewport): Point {
const scale = Math.max(vp.width / vp.frameWidth, vp.height / vp.frameHeight)
const ox = (vp.width - vp.frameWidth * scale) / 2
const oy = (vp.height - vp.frameHeight * scale) / 2
const x = vp.mirror ? 1 - p.x : p.x
return { x: ox + x * vp.frameWidth * scale, y: oy + p.y * vp.frameHeight * scale }
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Some files were not shown because too many files have changed in this diff Show More