本文基于魔珐星云 JS SDK 实战,完整可运行代码附上,踩坑经验一并分享。
一、被Keep虐了三个月后,我决定自己做一个
作为一个996福报受害者,我之前健身的"全流程"是这样的:
打开Keep → 选一个课程 → 跟着做 → 做了一半觉得无聊 → 刷手机 → 忘了做完了没 → 第二天腿酸但不知道练的哪块。
坚持了三个月,体重纹丝不动,肌肉酸痛倒是很稳定。
问题出在哪?我想了想,大概是没有反馈感。你对着手机做动作,手机只知道计数,不知道你动作标不标准、喘成什么样、需不需要调整节奏。练得好没人夸,练得差没人管——就像一个没有教练的自习室。
后来我接触到魔珐的星云SDK,突然有了一个想法:如果有一个数字人私教,能看着你做动作,能实时给反馈,能在你快放弃的时候推你一把——那健身效果会不会好很多?
二、现有应用的问题在哪?
在动手之前,我先研究了一下目前主流健身App的技术方案,大致可以分为这几类:
方案一:视频跟练类(Keep、Fit等)
-
技术实现:预录视频 + 动作计数
-
核心问题:用户动作错了没人知道,教练形象是视频里的,不是"我的"
-
用户体验:像对着DVD做操,孤独感极强
方案二:AI动作识别类(部分新兴App)
-
技术实现:摄像头捕捉 + 骨骼点检测 + 规则判断
-
核心问题:只能判断"对不对",不能告诉你"为什么"和"怎么做更好"
-
用户体验:像一个苛刻的裁判,做错了就扣分,但没有鼓励
我想要的,是一个有温度、能对话、可交互的数字人教练。他能看到我的训练数据,能在我偷懒的时候推我一把,能根据我的身体状态实时调整计划——不是冰冷的视频,也不是昂贵的真人私教。
三、为什么选魔珐星云SDK?
选星云的理由很简单:它真的能做出"会说话"的数字人,而不是一个仅能执行预制动画的单向展示工具**。**我对比了一下传统数字人与星云数字人的技术方案:
星云的核心思路是AI 端渲与端侧解算:基于自研文生 3D 多模态大模型,数字人的动画碎片预先下载到本地,对话时只传输几十KB的口型/表情参数,由本地SDK实时合成画面。这直接绕开了视频传输的高延迟问题——端到端对话响应从3-5秒压缩到500ms左右。
点击官网抢先体验:xingyun3d.com/
四、从零搭建:智能健身私教完整方案
下面我用星云SDK(JS版本)实际搭建一个可运行的智能健身顾问。
准备工作
星云官网注册账号(xingyun3d.com/)
创建应用驱动并保存 App ID 和 App Secret,这是后续接入SDK的唯一凭证文本大模型APIKey获取
ASR服务商,我选的是讯飞
4.1 项目结构
smart-fitness-advisor/
├── src/
│ ├── App.vue # 主界面(健身顾问UI)
│ ├── components/
│ │ └── AvatarRender.vue # 数字人渲染组件
│ ├── services/
│ │ ├── AvatarService.ts # 数字人服务封装
│ │ ├── FitnessService.ts # 健身逻辑服务
│ │ └── LLMService.ts # AI对话服务
│ └── stores/
│ └── app.ts # 全局状态管理
4.2 核心服务:AvatarService 封装
数字人的所有交互都围绕 XmovAvatar 实例展开。我将它封装成一个单例服务:
// src/services/AvatarService.ts
import { ref } from 'vue'
// 健身状态枚举
export type FitnessState = 'idle' | 'listen' | 'think' | 'speak' | 'demo'
// 健身建议数据
const fitnessSuggestions = [
{ tag: '热身', content: '运动前做5分钟动态拉伸,激活关节,防止受伤。' },
{ tag: '核心', content: '核心训练要注意呼吸配合,发力时呼气,还原时吸气。' },
{ tag: '力量', content: '力量训练每组做到力竭,最后1-2个动作最难,但最有效。' },
{ tag: '拉伸', content: '拉伸时要感到轻微酸痛,但不要到疼痛的程度,保持30秒。' },
{ tag: '有氧', content: '有氧训练保持心率在最大心率的60%-80%,效果最好。' },
]
class AvatarService {
private static instance: AvatarService | null = null
private avatar: any = null
private currentState: FitnessState = 'idle'
// 健身相关状态
public todayCalories = ref(0)
public todayMinutes = ref(0)
public streak = ref(3)
public currentExercise = ref<string | null>(null)
private constructor() {}
public static getInstance(): AvatarService {
if (!AvatarService.instance) {
AvatarService.instance = new AvatarService()
}
return AvatarService.instance
}
public async init(containerId: string, appId: string, appSecret: string) {
if (this.avatar) return
this.avatar = new (window as any).XmovAvatar({
containerId,
appId,
appSecret,
gatewayServer: 'https://nebula-agent.xingyun3d.com/user/v1/ttsa/session',
hardwareAcceleration: 'prefer-hardware',
enableLogger: true,
onMessage: (msg: any) => {
console.log('[SDK] 消息:', msg)
},
onStateChange: (state: string) => {
console.log('[SDK] 状态变化:', state)
this.currentState = state as FitnessState
},
onVoiceStateChange: (status: string) => {
console.log('[SDK] 语音状态:', status)
if (status === 'voice_end') {
this.avatar?.interactiveIdle()
}
},
onDownloadProgress: (progress: number) => {
console.log(`[SDK] 资源加载: ${progress}%`)
},
})
await this.avatar.init()
console.log('[SDK] 数字人初始化完成')
}
// 健身引导说话
public speakFitnessAdvice(exercise: string, advice: string) {
const ssml = `<speak>
<action name="gesture" param="point_right" />
今天我们来做${exercise}。${advice}
</speak>`
this.avatar?.speak(ssml, true, true)
}
// 鼓励用户
public speakEncouragement() {
const encouragements = [
'太棒了!继续保持这个节奏!💪',
'你的动作越来越标准了!',
'不错不错,继续加油!汗水不会骗人!',
'感觉到了吗?这就是进步的味道!',
]
const msg = encouragements[Math.floor(Math.random() * encouragements.length)]
this.avatar?.speak(msg, true, true)
}
// 切换状态
public setState(state: FitnessState) {
switch (state) {
case 'idle':
this.avatar?.idle()
break
case 'listen':
this.avatar?.listen()
break
case 'think':
this.avatar?.think()
break
case 'demo':
this.avatar?.interactiveIdle()
break
}
}
// 更新健身数据
public updateFitnessData(exercise: string, calories: number, minutes: number) {
this.currentExercise.value = exercise
this.todayCalories.value += calories
this.todayMinutes.value += minutes
// 训练完成后给予鼓励
this.speakEncouragement()
}
// 获取健身建议
public getFitnessSuggestion(tag: string): string {
const suggestion = fitnessSuggestions.find(s => s.tag === tag)
return suggestion?.content || '坚持就是胜利!'
}
public destroy() {
this.avatar?.destroy()
this.avatar = null
}
}
export const avatarService = AvatarService.getInstance()
4.3 健身逻辑服务
// src/services/FitnessService.ts
export interface Exercise {
id: number
name: string
icon: string
duration: number // 分钟
level: '入门' | '初级' | '中级' | '高级'
calories: number // 预计消耗卡路里
benefits: string
}
export const exerciseLibrary: Exercise[] = [
{
id: 1,
name: '热身运动',
icon: '🔥',
duration: 5,
level: '入门',
calories: 30,
benefits: '激活身体肌肉,预防运动损伤'
},
{
id: 2,
name: '核心训练',
icon: '💪',
duration: 15,
level: '初级',
calories: 120,
benefits: '增强核心力量,提高身体稳定性'
},
{
id: 3,
name: '力量训练',
icon: '🏋️',
duration: 20,
level: '中级',
calories: 180,
benefits: '增加肌肉力量,塑造健美体型'
},
{
id: 4,
name: '有氧运动',
icon: '🏃',
duration: 30,
level: '初级',
calories: 250,
benefits: '提升心肺功能,高效燃烧脂肪'
},
{
id: 5,
name: '拉伸放松',
icon: '🧘',
duration: 10,
level: '入门',
calories: 40,
benefits: '缓解肌肉酸痛,提高身体柔韧性'
},
{
id: 6,
name: '全身燃脂',
icon: '⚡',
duration: 25,
level: '高级',
calories: 300,
benefits: '全身肌肉参与,快速燃脂塑形'
},
]
export class FitnessService {
private static instance: FitnessService | null = null
public todayProgress = ref(0)
private constructor() {}
public static getInstance(): FitnessService {
if (!FitnessService.instance) {
FitnessService.instance = new FitnessService()
}
return FitnessService.instance
}
// 开始训练
public startExercise(exercise: Exercise): string {
const template = `好的,让我们开始${exercise.name}!这个动作主要锻炼${exercise.benefits}。建议训练时长${exercise.duration}分钟,我来给你计时,开始吧!`
return template
}
// 完成训练
public completeExercise(exercise: Exercise): { calories: number; minutes: number } {
this.todayProgress.value = Math.min(100, this.todayProgress.value + 20)
return {
calories: exercise.calories,
minutes: exercise.duration
}
}
// 获取每日建议
public getDailyTip(): string {
const tips = [
'运动前记得补充水分,运动中也要适当补水。',
'保持呼吸均匀,这有助于提高运动效果。',
'每天坚持30分钟,您会看到明显的进步!',
'运动后要做拉伸,帮助肌肉恢复。',
'合理的休息同样重要,给身体恢复的时间。',
'记住,运动要循序渐进,不要急于求成。',
]
return tips[Math.floor(Math.random() * tips.length)]
}
}
4.4 前端界面
<!-- src/App.vue 核心部分 -->
<script setup lang="ts">
import { ref, onMounted, provide } from 'vue'
import SdkRender from './components/AvatarRender.vue'
import { avatarService } from './services/AvatarService'
import { exerciseLibrary, FitnessService } from './services/FitnessService'
const fitnessService = FitnessService.getInstance()
const selectedExercise = ref<number | null>(null)
const currentAdvice = ref('您好!我是您的智能健身私教。今天想做什么样的运动呢?我可以帮您制定计划、实时指导动作。')
const todayProgress = ref(45)
provide('avatarService', avatarService)
// 选择训练项目
function selectExercise(id: number) {
selectedExercise.value = id
const exercise = exerciseLibrary.find(e => e.id === id)
if (exercise) {
currentAdvice.value = fitnessService.startExercise(exercise)
avatarService.speakFitnessAdvice(exercise.name, exercise.benefits)
}
}
// 完成训练
function completeExercise() {
if (selectedExercise.value) {
const exercise = exerciseLibrary.find(e => e.id === selectedExercise.value)
if (exercise) {
const result = fitnessService.completeExercise(exercise)
avatarService.updateFitnessData(exercise.name, result.calories, result.minutes)
todayProgress.value = fitnessService.todayProgress.value
currentAdvice.value = `太棒了!你完成了${exercise.name},消耗了约${result.calories}卡路里!继续保持!`
}
}
}
// 获取随机建议
function getRandomAdvice() {
currentAdvice.value = fitnessService.getDailyTip()
avatarService.speak(currentAdvice.value, true, true)
}
// 开始今日训练
function startTodayWorkout() {
currentAdvice.value = '很好!让我们开始今天的训练。先做5分钟热身,然后进入主要训练内容。准备好了吗?跟着我的节奏动起来!'
avatarService.setState('demo')
selectedExercise.value = 1
}
</script>
<template>
<div class="main">
<!-- 左侧:训练菜单 -->
<div class="sidebar">
<div class="logo">🏃 智能健身私教</div>
<div class="progress-section">
<div class="progress-label">今日进度</div>
<div class="progress-bar">
<div class="progress-fill" :style="{ width: todayProgress + '%' }"></div>
</div>
<div class="progress-text">{{ todayProgress }}%</div>
</div>
<div class="exercise-list">
<div
v-for="item in exerciseLibrary"
:key="item.id"
class="exercise-item"
:class="{ active: selectedExercise === item.id }"
@click="selectExercise(item.id)"
>
<div class="exercise-icon">{{ item.icon }}</div>
<div class="exercise-info">
<div class="exercise-name">{{ item.name }}</div>
<div class="exercise-meta">
{{ item.duration }}分钟 · {{ item.level }} · 🔥{{ item.calories }}卡
</div>
</div>
</div>
</div>
<div class="actions">
<button class="btn-primary" @click="startTodayWorkout">
🚀 开始训练
</button>
<button
v-if="selectedExercise"
class="btn-complete"
@click="completeExercise"
>
✅ 完成训练
</button>
</div>
</div>
<!-- 中间:数字人 + 指导 -->
<div class="center">
<div class="advice-card">
<div class="advice-label">💡 私教指导</div>
<div class="advice-text">{{ currentAdvice }}</div>
<button class="advice-refresh" @click="getRandomAdvice">
🔄 换个建议
</button>
</div>
<div class="avatar-container">
<SdkRender />
</div>
</div>
<!-- 右侧:数据面板 -->
<div class="stats-panel">
<div class="stats-title">📊 训练数据</div>
<div class="stats-grid">
<div class="stat-item">
<div class="stat-value">{{ avatarService.todayCalories.value }}</div>
<div class="stat-label">今日消耗(卡)</div>
</div>
<div class="stat-item">
<div class="stat-value">{{ avatarService.todayMinutes.value }}</div>
<div class="stat-label">训练时长(分)</div>
</div>
<div class="stat-item">
<div class="stat-value">{{ avatarService.streak.value }}</div>
<div class="stat-label">连续天数</div>
</div>
</div>
<div class="weekly-chart">
<div class="chart-title">本周训练</div>
<div class="bars">
<div class="bar-item" v-for="(height, i) in [60,80,40,90,70,50,30]" :key="i">
<div class="bar" :style="{ height: height + '%' }"></div>
<div class="bar-label">{{ ['一','二','三','四','五','六','日'][i] }}</div>
</div>
</div>
</div>
<div class="tip-card">
<div class="tip-title">💬 今日小贴士</div>
<div class="tip-text">{{ fitnessService.getDailyTip() }}</div>
</div>
</div>
</div>
</template>
4.5 数字人组件
<!-- src/components/AvatarRender.vue -->
<script setup lang="ts">
import { onMounted, onUnmounted } from 'vue'
import { avatarService } from '../services/AvatarService'
const APP_ID = import.meta.env.VITE_XINGYUN_APP_ID
const APP_SECRET = import.meta.env.VITE_XINGYUN_APP_SECRET
onMounted(async () => {
try {
await avatarService.init('avatar-container', APP_ID, APP_SECRET)
avatarService.setState('idle')
// 初始化完成后自动打招呼
setTimeout(() => {
avatarService.speak('你好!我是你的智能健身私教。今天准备好训练了吗?', true, true)
}, 2000)
} catch (e) {
console.error('数字人初始化失败:', e)
}
})
onUnmounted(() => {
avatarService.destroy()
})
</script>
<template>
<div id="avatar-container" class="avatar-wrapper"></div>
</template>
<style scoped>
.avatar-wrapper {
width: 100%;
height: 100%;
min-height: 400px;
}
</style>
4.6 运行
打开浏览器访问 http://localhost:5173,点击「初始化数字人」按钮。等待3D资源加载完成后(首次大约10-20秒),你就能看到一个活灵活现的数字人出现在页面上了。
在输入框输入文本,点击「让TA说」——数字人会用选定的音色开口说话,口型、表情、手势全部实时生成。
五、关键技术解析
5.1 流式对话:边生成边说话
这是数字人健身私教最核心的能力。大模型的输出是流式的(比如豆包、通义千问),用户不需要等它全部生成完再说出来。
// 模拟大模型流式输出 → 数字人实时播报
async function chatWithCoach(userMessage: string) {
// 显示用户消息
appendMessage('user', userMessage)
// 模拟大模型流式输出
const response = await streamLLMResponse(userMessage)
// 关键:数字人边接收边说话
let isFirstChunk = true
for await (const chunk of response) {
const isLastChunk = isLastResponseChunk(response, chunk)
avatarService.avatar.speak(chunk.text, isFirstChunk, isLastChunk)
isFirstChunk = false
// 实时追加到聊天框
appendMessage('coach', chunk.text)
}
// 播报结束,切换回空闲状态
avatarService.setState('idle')
}
关键规则:
-
第一段:
is_start = true -
最后一段:
is_end = true -
两段 speak 之间必须用
interactiveIdle()或listen()做状态切换(这里的"两段 speak"指的是两件不相关的事,不是流式输出的多个 chunk。)
正确理解:****is_start / is_end 是针对「一次对话轮次」的
一次完整的数字人说话,内部可以分成多个 speak() 调用(比如流式输出时每个 chunk 调一次),但这一整个轮次只需要一组 is_start=true 和 is_end=true****。
例如:
用户问:"推荐一个练腹的动作"
数字人回答(流式,分3段输出):
chunk1: "推荐你做卷腹。" → speak(chunk1, is_start=true, is_end=false)
chunk2: "这个动作主要锻炼上腹。" → speak(chunk2, is_start=false, is_end=false)
chunk3: "每组15个,做3组。" → speak(chunk3, is_start=false, is_end=true)
核心原则:同一轮回答的多个 chunk 是一个原子操作,中间不能被状态切换打断;只有两轮回答之间才需要状态隔离。
5.2 健身状态机设计
数字人在健身场景中的状态流转:
待机(idle) → 用户选择训练项目
↓
引导演示(demo) → 数字人演示动作,用户跟练
↓
倾听(listen) → 数字人观察用户状态,等待用户反馈
↓
思考(think) → 分析用户表现,准备评价
↓
反馈(speak) → 给出评价和建议
↓
鼓励(speak) → 正向激励,提升用户动力
↓
待机(idle) → 进入下一轮或结束
这个状态机保证了数字人的行为是"有目的"的,不是随机执行动画。
5.3 SSML 动作标记:让数字人做健身动作
星云的 SSML 支持在说话时触发预设动作(KA,Key Action),可以让数字人在演示健身动作时更生动:
// 数字人一边演示拉伸动作,一边说话
function demoStretch() {
const ssml = `<speak>
<ue4event>
<type>ka</type>
<data><action_semantic>stretch_arm_right</action_semantic></data>
</ue4event>
跟着我做——右手伸直,向左伸展,保持30秒。感受到了吗?右肩有拉伸感。
</speak>`
avatarService.avatar.speak(ssml, true, true)
}
通过 action_semantic 可以查询当前数字人角色支持的所有动作列表。首次加载时动作素材会从CDN下载(每个约100KB),后续直接走本地缓存。
六、踩坑记录整理
坑1:容器宽高必须明确指定
现象: init 成功,控制台无报错,但页面一片空白。
原因: SDK 内部用容器的 offsetWidth 和 offsetHeight 创建画布。用 flex 或 `height: auto` 初始化时都是 0。
解决:
<!-- ✅ 正确 -->
<div id="avatar-container" style="width: 540px; height: 960px;"></div>
<!-- ❌ 错误 -->
<div id="avatar-container" style="width: 100%;"></div>
坑2:只能 localhost 或 HTTPS 下运行
现象: 用局域网IP访问(如 `192.168.1.100:5173`),SDK 报错。
原因: SDK 用了麦克风、WebGL 等受限制的浏览器API,这些只在安全上下文(localhost/HTTPS)下可用。
解决: 开发用 localhost,部署必须上 HTTPS。可以用 ngrok 做本地映射测试。
坑3:健身数据没有持久化
现象: 刷新页面后,今天的训练数据全没了。
原因: 数据都在内存里(ref),没做本地存储。
解决: 加一个 localStorage 持久化:
// 保存
localStorage.setItem('fitness_today', JSON.stringify({
calories: avatarService.todayCalories.value,
minutes: avatarService.todayMinutes.value,
date: new Date().toDateString()
}))
// 读取
const saved = localStorage.getItem('fitness_today')
if (saved) {
const data = JSON.parse(saved)
if (data.date === new Date().toDateString()) {
avatarService.todayCalories.value = data.calories
avatarService.todayMinutes.value = data.minutes
}
}
七、总结:这套方案的真实体验
用了两周搭完这个系统,说说我的感受:
真正打动我的地方:
- 1秒响应:实测从用户选择训练项目到数字人开始说话,稳定在 900-1100ms。对比视频跟练 App 的"无人感",这个体验是质变。
- 有温度的交互:数字人会在你完成训练后说"太棒了",会在你想偷懒时说"再坚持一下"。这种即时反馈是纯文字或视频给不了的。
- 端侧渲染,成本可控:不需要为每个用户配备 GPU 服务器,素材缓存后复用,大规模部署的可行性很高。
需要注意的地方:
-
首次加载 10-20 秒,需要加 loading 引导
-
动作演示和语音的时序对齐需要手动调
-
数据持久化要自己做,SDK 不提供
-
HTTPS 是硬性要求,调试环境要注意
适合的场景 vs 不适合的场景:
如果你想做一个"真正能陪你练"的数字人教练,而不是一个"仅能执行预制动画的单向展示工具",星云 SDK + 健身业务逻辑的这套组合是目前我看到最可行的方案。它把最难的部分(数字人渲染、表情联动、实时响应)替你解决了,你只需要专注健身业务的体验设计。
相关资源
-
魔珐星云官网:xingyun3d.com
-
星云SDK文档:www.xingyun3d.com/developers
如果你也对这个方向感兴趣,欢迎评论区交流。觉得有用的话,转发一下,让更多人看到数字人健身私教的可能性。
专属体验链接:xingyun3d.comutm_campaign=daily&utm_source=jixinghuiKoc87