让文物开口说话:具身交互智能驱动的博山炉讲解数字人「青袅」
摘要:具身交互智能,是让 AI 从「屏幕里的文字」走向「有形象、能开口、会表达」的关键一步。本文记录一次具身交互智能的轻量落地:在一张「博山炉器物展陈」静态网页上,接入魔珐星云 XmovAvatar SDK,搭建一位名为「青袅」的历史讲解数字人。区别于带 LLM 对话的复杂应用,本项目是纯原生 JS(无框架、无构建)的轻量实现:数字人以透明背景合成进展陈画面,站在文案与器物之间,点击任意处即以固定讲解词开口,通过 TTSA(文本到语音动画)完成一次完整的文物讲解。文章覆盖展陈立意、魔珐星云控制台四要素配置、三栏式博物馆构图、核心接入源码逐段解析,并重点复盘一个真实踩坑——数字人画布被裁切「缺半个身子、抬手臂被切」的根因与三层解法。读完即可把一位会讲解的具身交互智能数字人,稳稳地「请」进任何一张静态展陈页。
**魔珐星云 PC 端官方链接**:https://xingyun3d.com?utm_campaign=daily&utm_source=CSDNwanfen3&utm_medium=&utm_term=&utm_content=
一、展陈立意:为什么给一件文物配「历史讲解数字人」
在动手写代码之前,先说清楚这个页面在解决什么问题——这也是整篇文章「为什么这样做」的起点。
一张文物展陈页,视觉可以很讲究:写实的博山炉居中而立,左侧是图录式文案(品名、别名、引言、工艺特征、色板),底部一条进度线。但它始终是「静」的——观众看得到器物,却听不到故事。博山炉的妙处恰恰在于「动」:焚香时青烟沿炉盖山峦的镂孔袅袅升起,宛如云雾缭绕仙山。这层意境,靠静态图文很难传达。
于是我们的决策是:在不打扰器物主体的前提下,引入具身交互智能——加入一位数字讲解员,让她用可见的形象、可听的声音,把器物背后的历史与意象讲出来。这正是具身交互智能的价值:让信息从静态图文,变成有形象、有温度的「人」在讲。几个关键取舍:
- 一次性讲解,而非问答对话。本页目标是「导览讲解」而非「智能客服」,因此不接入 LLM 与 ASR,只用魔珐星云的 TTSA 能力播报一段固定讲解词。链路更短、依赖更少、加载更快,也更契合展陈场景。
- 透明背景合成,融入而非遮挡。数字人以透明画布叠在展陈画面上,站在文案与器物之间,而不是占满半屏的独立窗口。
- 命名与人设。取「袅袅青烟」之意,为她定名「青袅」,定位「历史讲解数字人」,并在她脚边立一块博物馆式「展签」显示名片。名字、定位、讲解词全部收敛到配置文件,作为唯一事实来源。
明确了这三条,后面的控制台配置、代码结构、显示调优,都是围绕它们展开的。
二、魔珐星云控制台:为青袅配置形象・场景・音色・表演
魔珐星云 XmovAvatar 采用参数流架构,端侧解算、响应延迟低。要让青袅「活」起来,先要在控制台完成一个驱动应用的配置。以下步骤对应控制台实际操作界面。
步骤1:创建驱动应用
登录魔珐星云控制台,进入应用管理创建新的驱动应用,填写应用名称(如「青袅・历史讲解数字人」)与备注,并选择预览模式,便于边配边看效果。

步骤2:形象配置
选择与「历史讲解」气质相符的数字人形象。讲解员宜端庄亲和、表达得体,避免过于随意或过于商务,让观众愿意驻足聆听。

步骤3:场景配置
由于本页要透明背景合成,场景以简洁、便于抠像融入为宜。青袅最终会站在展陈页的深黑偏绿底色之上,因此场景无需复杂陈设。

步骤4:音色配置
选择契合讲解员角色的 AI 音色,并精细调校:
- 语速:中等偏慢,文物讲解需要观众跟得上、听得清;
- 语调:温润稳重、有叙事感,贴合「青烟缭绕仙山」的意境;
- 音量:按实际播放环境调整。

步骤5:表演配置
设置待机与讲解时的动作风格。讲解场景推荐自然站姿 + 适度手势引导,动作幅度不宜过大——这一点在后文的「显示调优」中还会再次提到:抬手臂的幅度直接关系到画布是否会被裁切。

步骤6:获取并配置密钥
完成四要素配置后,保存并复制应用的 App ID 与 App Secret,填入本地项目的配置文件中(下一章的 showcase-config.js)。密钥请妥善保管,勿提交到公开仓库。
三、页面骨架:三栏式博物馆构图(文案・器物・青袅)

整张展陈页在视觉上是「三栏」布局:左侧图录文案、中部写实器物、右侧数字讲解员。三者各占其位、层级分明。数字讲解员相关的 DOM 结构非常克制,只有一个承载 SDK 画布的容器、一块展签名片,以及字幕与状态提示:
<!-- 数字讲解员(魔珐星云 XmovAvatar) -->
<div class="guide" aria-label="数字讲解员 青袅">
<div id="avatarBox" class="guide__box"></div>
<!-- 展签:讲解员名片 -->
<div class="guide__nameplate" aria-hidden="true">
<span id="guideName" class="guide__name-zh"></span>
<span id="guideNameEn" class="guide__name-en"></span>
<span id="guideRole" class="guide__name-role"></span>
</div>
<div id="guideSubtitle" class="guide__subtitle" role="status" aria-live="polite"></div>
<div id="guideStatus" class="guide__status">讲解员准备中…</div>
</div>
<script type="module" src="./showcase.js"></script>
<script type="module" src="./guide.js"></script>
结构说明:
- avatarBox 是给 SDK 画布用的挂载点,SDK 会在其内部再注入一层带随机 id 的容器与 canvas;
- guide__nameplate 是脚边的博物馆式展签,三行文字(中文名 / 英文名 / 定位)在运行时由脚本按配置填充,DOM 里留空即可;
- guideSubtitle 与 guideStatus 分别承载讲解字幕与「点击开始讲解」的状态提示;
- 页面用原生 ES Module 直接引入 showcase.js(器物与文案动画)与 guide.js(数字讲解员),无需打包构建。
四、核心代码讲解
本章从项目真实源码出发,逐段解析青袅的接入实现。全部代码位于 showcase-config.js 与 guide.js 两个文件。
4.1 配置集中管理:showcase-config.js 的 avatar 块
功能定位:把密钥、网关、SDK 地址、超时、讲解员身份与讲解词,全部集中到一处配置,作为唯一事实来源,避免在 HTML/JS 中散落硬编码。以下为脱敏后的配置(真实密钥请替换占位符):
// showcase-config.js —— 数字讲解员(魔珐星云 XmovAvatar SDK)
avatar: {
appId: 'your_avatar_app_id',
appSecret: 'your_avatar_app_secret',
gatewayUrl: 'https://nebula-agent.xingyun3d.com/user/v1/ttsa/session',
dataSource: '2',
customId: 'demo',
cryptoUrl: 'https://cdnjs.cloudflare.com/ajax/libs/crypto-js/4.1.1/crypto-js.js',
sdkUrl: 'https://media.xingyun3d.com/xingyun3d/general/litesdk/xmovAvatar@latest.js',
initTimeout: 3000, // new XmovAvatar() 后等待内部就绪的时间
connectTimeout: 15000,
// 讲解员身份
name: '青袅',
nameEn: 'QING NIAO',
role: '历史讲解数字人',
// 讲解词(口语化,适合语音播报;开场自报家门)
guideScript:
'您好,我是青袅,本次展陈的讲解员。您眼前这件,是盛行于两汉时期的博山炉。' +
'炉盖被匠人铸成层叠的仙山峰峦,象征传说中的海上仙山。' +
'焚香时,袅袅青烟会从山间的镂孔升起,宛如云雾缭绕群峰,' +
'寄托着古人对长生与仙境的向往。',
},
关键逻辑讲解:
- appId / appSecret:控制台步骤 6 获取的密钥,此处已脱敏为占位符;
- gatewayUrl 指向 TTSA 会话网关;dataSource 与 customId 会作为查询参数拼接到网关地址上;
- cryptoUrl / sdkUrl 是两个 CDN 脚本,运行时动态注入(SDK 依赖 crypto-js 做签名);
- initTimeout 与 connectTimeout 是两处兜底等待,应对 SDK 内部就绪与初始化进度回调可能不触发的情况;
- name / nameEn / role 驱动脚边展签;guideScript 是青袅要讲的固定讲解词,开头即自报家门。
4.2 guide.js:动态加载 SDK 与建立连接
功能定位:以纯 JS 复刻魔珐星云的接入流程——动态加载 CDN 脚本 → 构造 XmovAvatar → init → speak。先看工具与加载部分:
import { CONFIG } from './showcase-config.js'
const AV = CONFIG.avatar
/* ---------- 工具 ---------- */
function loadScript(src) {
return new Promise((resolve, reject) => {
if (document.querySelector(`script[src="${src}"]`)) return resolve()
const s = document.createElement('script')
s.src = src
s.onload = () => resolve()
s.onerror = () => reject(new Error('脚本加载失败: ' + src))
document.head.appendChild(s)
})
}
function genContainerId() {
const bytes = crypto.getRandomValues(new Uint8Array(8))
let id = ''
for (let i = 0; i < bytes.length; i++) id += bytes[i].toString(16).padStart(2, '0')
return 'CONTAINER_' + id
}
/* ---------- 连接 ---------- */
async function loadSDKs() {
await loadScript(AV.cryptoUrl)
// SDK 内部依赖 window.CryptoJSTest
if (!window.CryptoJSTest && window.CryptoJS) window.CryptoJSTest = window.CryptoJS
await loadScript(AV.sdkUrl)
if (!window.XmovAvatar) throw new Error('XmovAvatar SDK 未就绪')
}
关键逻辑讲解:
- loadScript 通过查询已存在的 script 标签实现幂等,避免重复注入;
- genContainerId 用 crypto.getRandomValues 生成随机十六进制 id,保证多实例不冲突;
- 一个易踩的坑:SDK 内部会读取 window.CryptoJSTest,而 CDN 上的 crypto-js 挂载的是 window.CryptoJS,因此加载后要手动做一次别名桥接,否则签名环节报错。
接着是连接流程:
async function connect() {
const containerId = genContainerId()
const inner = document.createElement('div')
inner.id = containerId
inner.style.cssText = 'width:100%;height:100%;'
box.appendChild(inner)
const url = new URL(AV.gatewayUrl)
url.searchParams.append('data_source', AV.dataSource)
url.searchParams.append('custom_id', AV.customId)
let resolveInit
const initDone = new Promise((r) => { resolveInit = r })
avatar = new window.XmovAvatar({
containerId: '#' + containerId,
appId: AV.appId,
appSecret: AV.appSecret,
enableDebugger: false,
gatewayServer: url.toString(),
onProxyWidgetEvent: () => {},
onStateChange: (state) => { console.log('[讲解员] state:', state) },
onMessage: (err) => { console.warn('[讲解员] message:', err && err.message ? err.message : err) },
onVoiceStateChange: (status) => {
console.log('[讲解员] voiceState:', status)
if (String(status).includes('end')) {
speaking = false
hideSubtitle()
setStatus('🔊 点击任意处,请' + (AV.name || '讲解员') + '重讲', false)
}
},
})
// 等待 SDK 内部就绪,再 init
await new Promise((r) => setTimeout(r, AV.initTimeout))
await avatar.init({
onDownloadProgress: (p) => {
setStatus(`讲解员载入 ${Math.round(p)}%`)
if (p >= 100) resolveInit(true)
},
onClose: () => { connected = false },
})
// 兜底超时:init 100% 回调可能不触发
await Promise.race([initDone, new Promise((r) => setTimeout(r, AV.connectTimeout))])
connected = true
}
关键逻辑讲解:
- 先在 avatarBox 内动态创建一层带随机 id 的 inner 容器,SDK 的 containerId 指向它;
- 网关地址用 URL 对象拼上 data_source 与 custom_id 两个查询参数;
- onVoiceStateChange 收到包含 end 的状态即代表讲解结束——此时复位说话标志、隐藏字幕,并把状态提示改为「请青袅重讲」;
- 构造后先等 initTimeout 再调用 init,给 SDK 内部一个就绪窗口;
- init 的 onDownloadProgress 回调驱动「载入百分比」提示,100% 时兑现 initDone;
- 最后用 Promise.race 对 initDone 与 connectTimeout 做兜底赛跑,防止进度回调不触发时永久卡住。
4.3 讲解触发:手势解锁 + speak + 字幕
功能定位:浏览器要求「用户手势后才能播放音频」,因此青袅不会自动开口,而是等首次点击/按键再讲。同时把讲解词转成 SSML 交给 SDK。
function generateSSML(text, { pitch = 1, speed = 1, volume = 1 } = {}) {
const map = { '<': '<', '>': '>', "'": ''', '"': '"', '&': '&' }
const t = text.replace(/\n+/g, '\n').replace(/[<>'"&]/g, (s) => map[s] || s)
return `<speak pitch="${pitch}" speed="${speed}" volume="${volume}">${t}</speak>`
}
function speakIntro() {
if (!connected || !avatar || speaking) return
const text = AV.guideScript || CONFIG.bodyZh
try {
speaking = true
showSubtitle(text)
setStatus('', true)
avatar.speak(generateSSML(text), true, true)
} catch (e) {
speaking = false
console.warn('[讲解员] speak 失败:', e)
}
}
async function start() {
try {
setStatus('讲解员连接中…')
await loadSDKs()
await connect()
setStatus('🔊 点击任意处,听' + (AV.name || '讲解员') + '讲解', false)
// 首次用户交互触发讲解(浏览器要求手势后才能播放音频)
const onGesture = () => { if (connected && !speaking) speakIntro() }
document.addEventListener('click', onGesture)
document.addEventListener('keydown', (e) => {
if (e.key === ' ' || e.key === 'Enter') onGesture()
})
// 重启按钮亦触发重听(本身即用户手势)
const rb = document.getElementById('restart')
if (rb) rb.addEventListener('click', () => { if (connected) { speaking = false; speakIntro() } })
} catch (e) {
console.error('[讲解员] 初始化失败:', e)
setStatus('讲解员连接失败(不影响器物展示)', false)
}
}
// 供外部调试
window.boshanGuide = {
speak: speakIntro,
isConnected: () => connected,
}
start()
关键逻辑讲解:
- generateSSML 先做转义再包裹 speak 标签,避免讲解词里的特殊字符破坏 SSML;
- speakIntro 用 speaking 标志做去重,讲解中不重复触发;开口时显示字幕、隐藏状态提示;avatar.speak 的两个 true 分别表示「本段是起始、也是结束」(一次性整段播报);
- start 串起整条链路:连接中提示 → 加载 SDK → 连接 → 就绪提示;随后绑定 click 与空格/回车作为解锁手势,并让底部「重启」按钮触发重听;末尾还挂了一个 window.boshanGuide 调试钩子;
- 即便连接失败也只降级提示「不影响器物展示」,保证展陈主体永远可用。
4.4 展签名片填充
功能定位:DOM 里的展签三行留空,运行时按配置写入,保证名字、英文、定位与讲解词同源。
// 填充展签名片(名字 / 英文 / 定位)
;(function fillNameplate() {
const set = (id, v) => { const el = document.getElementById(id); if (el && v) el.textContent = v }
set('guideName', AV.name)
set('guideNameEn', AV.nameEn)
set('guideRole', AV.role)
})()
关键逻辑讲解:立即执行函数在脚本载入即填充展签,用 textContent 写入纯文本(而非 innerHTML),既安全又简单;任一字段缺失则跳过,容错友好。
五、显示调优实战:让青袅「全身入镜」

这是本项目最值得复盘的一段。数字人接进来后,遇到了两个真实问题:静止时右半边身体被切、抬手臂时手臂被切。排查后定位到三个叠加的根因,并用三层 CSS 解法逐一化解——这套经验对任何「把 XmovAvatar 合成进自定义布局」的场景都通用。
根因分析:
- SDK 注入的 canvas 是 position: absolute,并带有内联的 inset 偏移与 transform: scale(…),普通样式表无法覆盖它的定位;
- SDK 会给内层容器内联写入 overflow: hidden,一旦数字人抬手臂超出容器,肢体就被裁掉;
- 画布向右的横出量与高度大致成正比——容器越高,向右溢出越多,越容易被视口右缘切掉。
三层解法(以下为项目真实 CSS):
/* —— 数字讲解员(移至右侧;容器采用数字人原生宽高比 1080:1920,
避免 SDK 因容器过窄而裁切右半身体;透明背景合成) —— */
.guide {
position: fixed;
right: 18vh; /* 余量随数字人高度缩放(用 vh):SDK 画布向右横出量≈高度相关,保证抬臂不被视口右缘裁切;适当左移 */
bottom: 3vh;
height: 80vh;
aspect-ratio: 1080 / 1920; /* 与数字人画面同比,SDK 完整渲染不裁切 */
width: auto;
max-width: 55vw; /* 仅作安全上限;不低于高度推导宽度,以免破坏宽高比 */
z-index: 3; /* 在器物(1)/暗角(2)之上,文案(5)/HUD(6)之下 */
pointer-events: none;
display: flex;
align-items: flex-end;
justify-content: center;
}
.guide__box {
position: relative;
width: 100%;
height: 100%;
overflow: visible; /* 不裁剪数字人肢体动作 */
background: transparent;
display: flex;
justify-content: center;
align-items: flex-end; /* 数字人底部对齐 */
}
/* SDK 注入的内层容器(带 containerId);SDK 会内联设 overflow:hidden,强制改为 visible 以不裁剪抬臂动作 */
.guide__box > div {
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: flex-end;
overflow: visible !important;
}
.guide__box :is(canvas, video) {
display: block;
width: auto;
height: auto;
max-width: none; /* 不限宽度,保持原生宽高比 */
max-height: 100%; /* 仅按容器高度缩放(与主项目一致),避免 SDK 裁切 */
object-fit: contain;
background: transparent !important;
}
逐层讲解:
- 第一层——比例对齐:给容器设 aspect-ratio: 1080/1920,与数字人画面同比,SDK 就不会因容器过窄而裁掉右半身;max-width 只作安全上限,且不能低于「按高度推导出的宽度」,否则反而压破比例。
- 第二层——放开裁切:SDK 内联的 overflow: hidden 是抬手臂被切的元凶,只能用 overflow: visible !important 才压得过内联样式;这是最关键的一行。
- 第三层——右缘留白:因为横出量随高度增大,右侧安全余量用 vh 单位(right: 18vh)表达最稳——无论近正方形窗还是宽屏,抬手臂都不会被视口右缘切掉;数值越大,数字人越往左。
调优后,青袅在静止与讲解(含抬臂手势)时都能全身入镜,与中部器物、左侧文案构成一幅均衡的三栏展陈画面。
经验小结:将 XmovAvatar 合成进自定义布局时,务必记住 SDK 会给内层容器内联 overflow: hidden、给 canvas 内联绝对定位;容器按 1080:1920 比例给,canvas 只锁高度不锁宽度,右缘用 vh 留白——三管齐下即可根治裁切。
六、总结与展望
本文完整演示了一次具身交互智能的轻量落地:在一张静态文物展陈页上,用纯原生 JS 接入魔珐星云 XmovAvatar SDK,搭建历史讲解数字人「青袅」,让文物讲解从静态图文升级为有形象、有声音的具身交互。关键要点回顾:
- 轻量接入:无框架、无构建,动态加载 CDN 脚本即可完成 构造 → init → speak 全流程;
- 一次性讲解:不引入 LLM/ASR,用 TTSA 播报固定讲解词,链路短、加载快、契合导览场景;
- 手势解锁:规避浏览器音频自动播放限制,点击/空格/回车触发,失败自动降级不影响器物展示;
- 配置同源:名字、定位、讲解词集中在配置文件,驱动展签与播报,脱敏后可安全开源;
- 显示调优:用「比例对齐 + overflow: visible !important + vh 右缘留白」三层解法,根治数字人被裁切的问题。
后续可拓展的方向:把固定讲解词升级为按文物切换的多段脚本;接入 ASR/LLM 让青袅支持观众追问;或将这套「透明合成 + 展签 + 手势解锁」的接入范式,沉淀为可复用的展陈数字人组件,让具身交互智能在更多文物、更多场景里开口讲述。
**魔珐星云 PC 端官方链接**:https://xingyun3d.com?utm_campaign=daily&utm_source=CSDNwanfen3&utm_medium=&utm_term=&utm_content=
DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。
更多推荐



所有评论(0)