摘要:具身交互智能,是让 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 = { '<': '&lt;', '>': '&gt;', "'": '&apos;', '"': '&quot;', '&': '&amp;' }
  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 合成进自定义布局」的场景都通用。

根因分析

  1. SDK 注入的 canvas 是 position: absolute,并带有内联的 inset 偏移与 transform: scale(…),普通样式表无法覆盖它的定位;
  2. SDK 会给内层容器内联写入 overflow: hidden,一旦数字人抬手臂超出容器,肢体就被裁掉;
  3. 画布向右的横出量与高度大致成正比——容器越高,向右溢出越多,越容易被视口右缘切掉。

三层解法(以下为项目真实 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=
Logo

DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。

更多推荐