让文物开口说话:具身交互智能驱动的博山炉讲解数字人「青袅」 摘要具身交互智能是让 AI 从「屏幕里的文字」走向「有形象、能开口、会表达」的关键一步。本文记录一次具身交互智能的轻量落地在一张「博山炉器物展陈」静态网页上接入魔珐星云 XmovAvatar SDK搭建一位名为「青袅」的历史讲解数字人。区别于带 LLM 对话的复杂应用本项目是纯原生 JS无框架、无构建的轻量实现数字人以透明背景合成进展陈画面站在文案与器物之间点击任意处即以固定讲解词开口通过 TTSA文本到语音动画完成一次完整的文物讲解。文章覆盖展陈立意、魔珐星云控制台四要素配置、三栏式博物馆构图、核心接入源码逐段解析并重点复盘一个真实踩坑——数字人画布被裁切「缺半个身子、抬手臂被切」的根因与三层解法。读完即可把一位会讲解的具身交互智能数字人稳稳地「请」进任何一张静态展陈页。**魔珐星云 PC 端官方链接**https://xingyun3d.com?utm_campaigndailyutm_sourceCSDNwanfen3utm_mediumutm_termutm_content一、展陈立意为什么给一件文物配「历史讲解数字人」在动手写代码之前先说清楚这个页面在解决什么问题——这也是整篇文章「为什么这样做」的起点。一张文物展陈页视觉可以很讲究写实的博山炉居中而立左侧是图录式文案品名、别名、引言、工艺特征、色板底部一条进度线。但它始终是「静」的——观众看得到器物却听不到故事。博山炉的妙处恰恰在于「动」焚香时青烟沿炉盖山峦的镂孔袅袅升起宛如云雾缭绕仙山。这层意境靠静态图文很难传达。于是我们的决策是在不打扰器物主体的前提下引入具身交互智能——加入一位数字讲解员让她用可见的形象、可听的声音把器物背后的历史与意象讲出来。这正是具身交互智能的价值让信息从静态图文变成有形象、有温度的「人」在讲。几个关键取舍一次性讲解而非问答对话。本页目标是「导览讲解」而非「智能客服」因此不接入 LLM 与 ASR只用魔珐星云的 TTSA 能力播报一段固定讲解词。链路更短、依赖更少、加载更快也更契合展陈场景。透明背景合成融入而非遮挡。数字人以透明画布叠在展陈画面上站在文案与器物之间而不是占满半屏的独立窗口。命名与人设。取「袅袅青烟」之意为她定名「青袅」定位「历史讲解数字人」并在她脚边立一块博物馆式「展签」显示名片。名字、定位、讲解词全部收敛到配置文件作为唯一事实来源。明确了这三条后面的控制台配置、代码结构、显示调优都是围绕它们展开的。二、魔珐星云控制台为青袅配置形象・场景・音色・表演魔珐星云 XmovAvatar 采用参数流架构端侧解算、响应延迟低。要让青袅「活」起来先要在控制台完成一个驱动应用的配置。以下步骤对应控制台实际操作界面。步骤1创建驱动应用登录魔珐星云控制台进入应用管理创建新的驱动应用填写应用名称如「青袅・历史讲解数字人」与备注并选择预览模式便于边配边看效果。步骤2形象配置选择与「历史讲解」气质相符的数字人形象。讲解员宜端庄亲和、表达得体避免过于随意或过于商务让观众愿意驻足聆听。步骤3场景配置由于本页要透明背景合成场景以简洁、便于抠像融入为宜。青袅最终会站在展陈页的深黑偏绿底色之上因此场景无需复杂陈设。步骤4音色配置选择契合讲解员角色的 AI 音色并精细调校语速中等偏慢文物讲解需要观众跟得上、听得清语调温润稳重、有叙事感贴合「青烟缭绕仙山」的意境音量按实际播放环境调整。步骤5表演配置设置待机与讲解时的动作风格。讲解场景推荐自然站姿 适度手势引导动作幅度不宜过大——这一点在后文的「显示调优」中还会再次提到抬手臂的幅度直接关系到画布是否会被裁切。步骤6获取并配置密钥完成四要素配置后保存并复制应用的 App ID 与 App Secret填入本地项目的配置文件中下一章的 showcase-config.js。密钥请妥善保管勿提交到公开仓库。三、页面骨架三栏式博物馆构图文案・器物・青袅整张展陈页在视觉上是「三栏」布局左侧图录文案、中部写实器物、右侧数字讲解员。三者各占其位、层级分明。数字讲解员相关的 DOM 结构非常克制只有一个承载 SDK 画布的容器、一块展签名片以及字幕与状态提示!--数字讲解员魔珐星云 XmovAvatar--divclassguidearia-label数字讲解员 青袅div idavatarBoxclassguide__box/div!--展签讲解员名片--divclassguide__nameplatearia-hiddentruespan idguideNameclassguide__name-zh/spanspan idguideNameEnclassguide__name-en/spanspan idguideRoleclassguide__name-role/span/divdiv idguideSubtitleclassguide__subtitlerolestatusaria-livepolite/divdiv idguideStatusclassguide__status讲解员准备中…/div/divscript typemodulesrc./showcase.js/scriptscript typemodulesrc./guide.js/script结构说明avatarBox 是给 SDK 画布用的挂载点SDK 会在其内部再注入一层带随机 id 的容器与 canvasguide__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 SDKavatar:{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/xmovAvatarlatest.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.jsconstAVCONFIG.avatar/* ---------- 工具 ---------- */functionloadScript(src){returnnewPromise((resolve,reject){if(document.querySelector(script[src${src}]))returnresolve()constsdocument.createElement(script)s.srcsrc s.onload()resolve()s.onerror()reject(newError(脚本加载失败: src))document.head.appendChild(s)})}functiongenContainerId(){constbytescrypto.getRandomValues(newUint8Array(8))letidfor(leti0;ibytes.length;i)idbytes[i].toString(16).padStart(2,0)returnCONTAINER_id}/* ---------- 连接 ---------- */asyncfunctionloadSDKs(){awaitloadScript(AV.cryptoUrl)// SDK 内部依赖 window.CryptoJSTestif(!window.CryptoJSTestwindow.CryptoJS)window.CryptoJSTestwindow.CryptoJSawaitloadScript(AV.sdkUrl)if(!window.XmovAvatar)thrownewError(XmovAvatar SDK 未就绪)}关键逻辑讲解loadScript 通过查询已存在的 script 标签实现幂等避免重复注入genContainerId 用 crypto.getRandomValues 生成随机十六进制 id保证多实例不冲突一个易踩的坑SDK 内部会读取 window.CryptoJSTest而 CDN 上的 crypto-js 挂载的是 window.CryptoJS因此加载后要手动做一次别名桥接否则签名环节报错。接着是连接流程asyncfunctionconnect(){constcontainerIdgenContainerId()constinnerdocument.createElement(div)inner.idcontainerId inner.style.cssTextwidth:100%;height:100%;box.appendChild(inner)consturlnewURL(AV.gatewayUrl)url.searchParams.append(data_source,AV.dataSource)url.searchParams.append(custom_id,AV.customId)letresolveInitconstinitDonenewPromise((r){resolveInitr})avatarnewwindow.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:,errerr.message?err.message:err)},onVoiceStateChange:(status){console.log([讲解员] voiceState:,status)if(String(status).includes(end)){speakingfalsehideSubtitle()setStatus( 点击任意处请(AV.name||讲解员)重讲,false)}},})// 等待 SDK 内部就绪再 initawaitnewPromise((r)setTimeout(r,AV.initTimeout))awaitavatar.init({onDownloadProgress:(p){setStatus(讲解员载入${Math.round(p)}%)if(p100)resolveInit(true)},onClose:(){connectedfalse},})// 兜底超时init 100% 回调可能不触发awaitPromise.race([initDone,newPromise((r)setTimeout(r,AV.connectTimeout))])connectedtrue}关键逻辑讲解先在 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。functiongenerateSSML(text,{pitch1,speed1,volume1}{}){constmap{:lt;,:gt;,:apos;,:quot;,:amp;}constttext.replace(/\n/g,\n).replace(/[]/g,(s)map[s]||s)returnspeak pitch${pitch} speed${speed} volume${volume}${t}/speak}functionspeakIntro(){if(!connected||!avatar||speaking)returnconsttextAV.guideScript||CONFIG.bodyZhtry{speakingtrueshowSubtitle(text)setStatus(,true)avatar.speak(generateSSML(text),true,true)}catch(e){speakingfalseconsole.warn([讲解员] speak 失败:,e)}}asyncfunctionstart(){try{setStatus(讲解员连接中…)awaitloadSDKs()awaitconnect()setStatus( 点击任意处听(AV.name||讲解员)讲解,false)// 首次用户交互触发讲解浏览器要求手势后才能播放音频constonGesture(){if(connected!speaking)speakIntro()}document.addEventListener(click,onGesture)document.addEventListener(keydown,(e){if(e.key ||e.keyEnter)onGesture()})// 重启按钮亦触发重听本身即用户手势constrbdocument.getElementById(restart)if(rb)rb.addEventListener(click,(){if(connected){speakingfalse;speakIntro()}})}catch(e){console.error([讲解员] 初始化失败:,e)setStatus(讲解员连接失败不影响器物展示,false)}}// 供外部调试window.boshanGuide{speak:speakIntro,isConnected:()connected,}start()关键逻辑讲解generateSSML 先做转义再包裹 speak 标签避免讲解词里的特殊字符破坏 SSMLspeakIntro 用 speaking 标志做去重讲解中不重复触发开口时显示字幕、隐藏状态提示avatar.speak 的两个 true 分别表示「本段是起始、也是结束」一次性整段播报start 串起整条链路连接中提示 → 加载 SDK → 连接 → 就绪提示随后绑定 click 与空格/回车作为解锁手势并让底部「重启」按钮触发重听末尾还挂了一个 window.boshanGuide 调试钩子即便连接失败也只降级提示「不影响器物展示」保证展陈主体永远可用。4.4 展签名片填充功能定位DOM 里的展签三行留空运行时按配置写入保证名字、英文、定位与讲解词同源。// 填充展签名片名字 / 英文 / 定位;(functionfillNameplate(){constset(id,v){consteldocument.getElementById(id);if(elv)el.textContentv}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;/* 余量随数字人高度缩放用 vhSDK 画布向右横出量≈高度相关保证抬臂不被视口右缘裁切适当左移 */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 注入的内层容器带 containerIdSDK 会内联设 overflow:hidden强制改为 visible 以不裁剪抬臂动作 */.guide__boxdiv{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_campaigndailyutm_sourceCSDNwanfen3utm_mediumutm_termutm_content