Merge branch 'master' of http://git.zhenyangtang.com.cn/ZYT/zyt
@@ -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
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@
|
||||
"requiredBackgroundModes" : [ "audio" ],
|
||||
"plugins" : {
|
||||
"WechatSI" : {
|
||||
"version" : "0.3.5",
|
||||
"version" : "0.3.9",
|
||||
"provider" : "wx069ba97219f66d99"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -179,7 +196,7 @@
|
||||
"mp-weixin": {
|
||||
"usingPlugins": {
|
||||
"WechatSI": {
|
||||
"version": "0.3.5",
|
||||
"version": "0.3.9",
|
||||
"provider": "wx069ba97219f66d99"
|
||||
}
|
||||
}
|
||||
@@ -196,7 +213,7 @@
|
||||
"mp-weixin": {
|
||||
"usingPlugins": {
|
||||
"WechatSI": {
|
||||
"version": "0.3.5",
|
||||
"version": "0.3.9",
|
||||
"provider": "wx069ba97219f66d99"
|
||||
}
|
||||
}
|
||||
@@ -213,7 +230,7 @@
|
||||
"mp-weixin": {
|
||||
"usingPlugins": {
|
||||
"WechatSI": {
|
||||
"version": "0.3.5",
|
||||
"version": "0.3.9",
|
||||
"provider": "wx069ba97219f66d99"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"/>',
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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))
|
||||
})
|
||||
@@ -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],
|
||||
},
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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` : ''
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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)
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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',
|
||||
}
|
||||
}
|
||||
@@ -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)}`
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 17 KiB |