当数字人住进画室:具身交互智能驱动的 Collab Canvas 全链路实战
一位住在墨色画室里的共画艺术伙伴墨白,能听、能说、有肢体表达——这正是具身交互智能的核心。本文从魔珐星云控制台配置到 React 18 + Vite 7 工程源码,拆解 Collab Canvas 如何以参数流 SDK 为中枢,让数字人嵌入涂鸦→对话→凝练→成画→留存的全链路创作流程。
魔珐星云 PC 端官方链接:https://xingyun3d.com?utm_campaign=daily&utm_source=CSDNwanfen2&utm_medium=&utm_term=&utm_content=
一、产品概览:当数字人成为画室伙伴
Collab Canvas(与 AI 共画)是一个把数字人嵌入创作流程的 Web 应用。它的核心体验不是"输入文字 → 生成图片"的简单工具流,而是一段完整的画室对话:
- 用户在宣纸质感的高 DPI 画板上涂鸦几笔
- 与数字人墨白进行流式语音对话,描述此刻想画什么
- 点击凝练成画,墨白分析整段对话,输出风格 / 情绪 / 色板 / 画面意象的 JSON 报告
- 报告被组装成文生图 prompt,生成一幅水墨水彩风格的成品画
- 涂鸦底稿 + 成品画 + 对话记录一并存入 IndexedDB 作品墙
在这条链路中,数字人不是装饰——她是具身交互智能的载体:能听、能说、有肢体表达,在创作过程中提供实时的语音反馈与情感陪伴。这区别于传统的图文分离工具,把 AI 的语言能力与人的视觉创作绑定在同一次交互里。
二、技术架构总览
项目采用 React 18 + Vite 7 + TypeScript 技术栈,整体分为五层:
| 层级 | 职责 | 核心文件 |
|---|---|---|
| 视图组件层 | 落地页 / 工作室 / 作品墙三视图 | App.tsx, Studio.tsx, DrawCanvas.tsx, AvatarStage.tsx |
| 状态层 | 视图切换 / 消息 / 作品 CRUD | stores/app.ts (zustand + idb-keyval) |
| 服务层 | SDK 连接 / 流式播报 / LLM / 画作分析 / 文生图 | services/* |
| 常量层 | 密钥 / 身份 / 存储键 | constants/index.ts |
| 设计系统 | 墨色画室 CSS 令牌 | index.css |
数字人核心逻辑以框架无关的单例 AvatarController 承载,通过订阅机制对接 React 的 useSyncExternalStore。这意味着数字人的连接、断句播报、实时打断等能力不依赖 React 的渲染周期,可以在任何框架中复用——这是具身交互智能落地的架构基础。
三、前提准备
魔珐星云控制台配置
数字人墨白的形象、场景、音色、表演均由魔珐星云控制台驱动。以下五步完成驱动应用创建与参数配置,是代码层接入 SDK 前的必要前提。
步骤1:魔珐星云控制台创建驱动应用
在魔珐星云控制台创建一个新的驱动应用,获取 appId 与 appSecret,这两个密钥将填入项目 constants/index.ts。

步骤2:形象配置
为数字人墨白选择或上传形象资源,配置肤色、发型、服装等外观参数。形象配置决定了 SDK 渲染时的角色外观,是具身交互智能中"有形象"这一要素的直接来源。

步骤3:场景配置
配置数字人所在的虚拟场景,包括灯光、背景、机位等。场景配置与前端 AvatarStage 容器配合,决定数字人在页面中的视觉呈现方式。

步骤4:音色配置
为墨白选择音色,控制语音合成的音色、语速、情感倾向。音色配置直接影响流式断句播报时的听感,是具身交互智能中"能说"这一要素的质量基础。

步骤5:表演配置
配置数字人的肢体动作与表情,包括待机动作、说话时的手势、情绪表情等。表演配置让数字人在朗读时有自然的肢体表达,而非僵硬的站桩——这是具身交互智能区别于纯语音助手的视觉支柱。

工程初始化
步骤6:创建项目并安装依赖
npm create vite@latest collab-canvas -- --template react-ts
cd collab-canvas
npm install zustand idb-keyval openai
项目的 package.json 锁定了以下依赖:
{
"dependencies": {
"idb-keyval": "^6.2.1",
"openai": "5.12.2",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"zustand": "^4.5.5"
},
"devDependencies": {
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.4",
"typescript": "~5.8.3",
"vite": "7.1.2"
}
}
- zustand 4.5 提供轻量全局状态(视图切换 / 消息列表 / 作品集)
- idb-keyval 6.2 用于 IndexedDB 持久化作品墙
- openai 5.12 以 dangerouslyAllowBrowser 模式直连豆包模型 API
步骤7:配置 Vite 构建端口
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
port: 5276
}
})
步骤8:填入魔珐星云与 LLM 密钥
将步骤1获取的 appId / appSecret 及 LLM 密钥填入 src/constants/index.ts(此处为脱敏占位):
export const API_KEYS = {
AVATAR: {
appId: 'your_avatar_app_id',
appSecret: 'your_avatar_app_secret'
},
LLM: {
apiKey: 'your_llm_api_key'
},
IMAGE: {
apiKey: 'your_image_api_key'
}
} as const
同时配置 SDK 网关、LLM 模型与文生图参数:
export const SDK_CONFIG = {
GATEWAY_URL: 'https://nebula-agent.xingyun3d.com/user/v1/ttsa/session',
DATA_SOURCE: '2',
CUSTOM_ID: 'demo'
} as const
export const LLM_CONFIG = {
BASE_URL: 'https://ark.cn-beijing.volces.com/api/v3',
DEFAULT_MODEL: 'doubao-1-5-pro-32k-250115',
TEMPERATURE: 0.85,
MAX_TOKENS: 1024
} as const
export const IMAGE_CONFIG = {
SIZE: 'landscape_4_3' as const,
API_BASE: 'https://trae-api-cn.mchost.guru/api/ide/v1'
} as const
数字人身份也在常量层定义,贯穿整个应用的问候语、字幕、名牌:
export const AVATAR_IDENTITY = {
name: '墨白',
role: '共画艺术伙伴',
greeting: '欢迎来到共画工作室。我是你的共画伙伴墨白。今天想一起画点什么?',
farewell: '愿你此刻,有所成画。'
} as const
export const STORAGE_KEY = 'collab-artworks' as const
四、技术选型与产品决策
1. 为什么选 React 18 而非 Vue? Collab Canvas 需要在画板、对话舱、数字人三个高频更新的区域之间并发渲染。React 18 的并发渲染能力配合 useSyncExternalStore,可以让数字人单例的状态更新不触发整树重渲染——画板绘制不会被字幕刷新打断。这是具身交互智能场景下多通道并发的刚需。
2. 为什么数字人逻辑写成框架无关单例? 数字人 SDK 的连接、断句、播报是有状态的时序逻辑,不属于任何一次渲染。把 AvatarController 写成纯 TypeScript 类,再通过 useSyncExternalStore 桥接 React,可以保证核心逻辑可测试、可迁移、不随框架版本变化而废弃。
3. 为什么作品存在 IndexedDB 而非后端? 共画是一个偏个人化的创作场景,用户不需要登录就能在本地留存作品。idb-keyval 的 get/set/del/keys 四个 API 足以覆盖作品墙的 CRUD,省去后端部署成本,让整个应用纯前端即可运行。
五、核心代码讲解


5.1 数字人控制器:流式断句播报引擎
AvatarController 是整个具身交互智能链路的中枢。它管理 SDK 连接、流式 LLM 断句、串行 SSML 队列、实时打断,并通过订阅机制对接 React。
流式断句是其中最关键的逻辑——LLM 逐字返回内容,但数字人不能每收到一个字就说一次,需要等积累到一定字数且遇到标点时才截断发送:
async *streamChat(userText: string, systemPrompt?: string): AsyncIterable<string> {
const trimmed = userText.trim()
if (!trimmed || !this.instance) return
if (!validateConfig(API_KEYS.LLM, ['apiKey'])) return
this.interrupt()
if (this.state.avatarState === 'speak') {
try { await this.waitForAvatarIdle() } catch { /* 即使超时也继续 */ }
}
this.actionManager.reset()
const messages = systemPrompt
? [{ role: 'system' as const, content: systemPrompt }, { role: 'user' as const, content: trimmed }]
: undefined
const stream = messages
? llmService.sendMessageWithStream(messages)
: llmService.sendMessageWithStream({ provider: 'openai', model: LLM_CONFIG.DEFAULT_MODEL, apiKey: API_KEYS.LLM.apiKey }, trimmed)
const minimum = 20
const context = { cache: '', chars: 0, firstSpeakSend: false, spaceCount: 0 }
try {
for await (const content of stream) {
if (typeof content !== 'string') continue
yield content
context.cache += content
if (content.startsWith(' ')) context.spaceCount += 1
const chars = content.match(/[\u4e00-\u9fa5a-zA-Z0-9]/g)?.length ?? 0
let shouldSend = false
if (!context.firstSpeakSend) {
shouldSend = context.spaceCount
? context.spaceCount > minimum - 1 && enSplitSign.test(content)
: context.chars > minimum && cnSplitSign.test(content)
} else {
shouldSend = context.spaceCount ? enSplitSign.test(content) : cnSplitSign.test(content)
}
if (!shouldSend) { context.chars += chars; continue }
this.actionManager.speak(context.cache, { isStart: !context.firstSpeakSend, isEnd: false })
context.firstSpeakSend = true
context.cache = ''; context.chars = 0; context.spaceCount = 0
}
if (context.cache.length > 0) {
this.actionManager.speak(context.cache, { isStart: !context.firstSpeakSend, isEnd: true })
} else if (context.firstSpeakSend) {
this.actionManager.speak('', { isStart: false, isEnd: true })
}
} catch (error) {
console.error('streamChat 失败:', error)
}
}
关键逻辑分析:
- systemPrompt 参数让调用方可以注入不同角色的人格(共画伙伴 vs 画作分析师),这是 Collab Canvas 对话舱与凝练成画两个场景共用一个控制器的关键
- minimum = 20 是首句最低字数门槛:中文按 cnSplitSign 检测句号/逗号,英文按 enSplitSign 检测标点 + 空格计数,首句积累够 20 字且遇到标点才发送,避免数字人开口太快
- yield content 让 React 组件可以同步消费每个 chunk 实时渲染字幕,数字人播报与字幕展示同源、零延迟
- isStart / isEnd 标记贯穿整个队列:首句标记 isStart 告知 SDK 开始一轮播报,末句标记 isEnd 告知 SDK 结束,中间句两端都不标记——这是魔珐参数流 SDK 串行播报的协议要求
5.2 串行 SSML 队列:保证不抢话
ActionManager 确保即便流式片段陆续到达,数字人也严格按顺序逐句播报:
export class ActionManager {
private queue: ActionQueueItem[] = []
private isSpeaking = false
private getInstance: () => any | null
private onVoiceReady?: () => void
private onVoiceEnd?: () => void
constructor(options: ActionManagerOptions) {
this.getInstance = options.getInstance
this.onVoiceReady = options.onVoiceReady
this.onVoiceEnd = options.onVoiceEnd
}
speak(text: string, options: SpeakOptions = {}) {
const ssml = generateSSML(text.replace(/\n+/g, '\n'))
this.queue.push({ ssml, isStart: options.isStart ?? false, isEnd: options.isEnd ?? false })
this.processQueue()
}
reset() { this.queue = []; this.isSpeaking = false }
private async processQueue() {
if (this.isSpeaking) return
if (!this.queue.length) return
const instance = this.getInstance()
if (!instance) return
this.isSpeaking = true
while (this.queue.length) {
const item = this.queue.shift()
if (!item) break
this.onVoiceReady?.()
instance.speak(item.ssml, item.isStart, item.isEnd)
if (!item.isEnd) continue
await new Promise((r) => setTimeout(r, 50))
}
this.isSpeaking = false
this.onVoiceEnd?.()
}
}
关键逻辑分析:
- isSpeaking 互斥锁保证队列串行执行:流式片段可能在 50ms 内连续到达三条,队列会先推入 buffer 再逐条消费,不会并行调用 SDK 的 speak
- isEnd 标记决定节奏:遇到 isEnd 的片段会额外等待 50ms,给 SDK 一个句子结束的信号;非 isEnd 片段连续推送不等待,保证长句中间不断顿
- reset() 用于实时打断场景:用户在数字人说话时发新消息,streamChat 开头调用 interrupt() → actionManager.reset() 清空队列,数字人立刻停嘴进入新对话——这是具身交互智能中"随时可打断"的实现
5.3 共画画板:高 DPI Canvas + 宣纸底
DrawCanvas 通过 forwardRef + useImperativeHandle 暴露 clear / exportDataURL / hasStrokes 三个方法给父组件,画板核心逻辑如下:
const DrawCanvas = forwardRef<DrawCanvasHandle, Props>(
({ tool, color, brushSize, onStrokeEnd }, ref) => {
const canvasRef = useRef<HTMLCanvasElement>(null)
const ctxRef = useRef<CanvasRenderingContext2D | null>(null)
const drawingRef = useRef(false)
const lastPtRef = useRef<{ x: number; y: number } | null>(null)
const [hasStrokes, setHasStrokes] = useState(false)
useEffect(() => {
const canvas = canvasRef.current
if (!canvas) return
const dpr = window.devicePixelRatio || 1
const rect = canvas.getBoundingClientRect()
canvas.width = rect.width * dpr
canvas.height = rect.height * dpr
const ctx = canvas.getContext('2d')
if (!ctx) return
ctx.scale(dpr, dpr)
ctx.lineCap = 'round'
ctx.lineJoin = 'round'
ctxRef.current = ctx
paintPaper(ctx, rect.width, rect.height)
}, [])
const paintPaper = (ctx: CanvasRenderingContext2D, w: number, h: number) => {
ctx.fillStyle = PAPER_BG
ctx.fillRect(0, 0, w, h)
ctx.save()
ctx.globalAlpha = 0.04
for (let i = 0; i < 200; i++) {
ctx.fillStyle = Math.random() > 0.5 ? '#5b8a9e' : '#2a2a2a'
ctx.fillRect(Math.random() * w, Math.random() * h, 1.5, 1.5)
}
ctx.restore()
}
关键逻辑分析:
- 高 DPI 适配:canvas.width = rect.width * dpr 将画布物理像素放大到设备像素比倍数,再 ctx.scale(dpr, dpr) 将绘图坐标系缩放回来,保证 Retina 屏下线条不糊
- paintPaper 宣纸底:先用宣纸米 #e8e0d0 铺底,再以 4% 透明度随机散布 200 个墨青/墨黑噪点,模拟宣纸纤维质感——这让用户落笔时有"在纸上画"的体感,而非冰冷的白色 canvas
- drawingRef / lastPtRef 用 ref 而非 state 存绘画中间态:pointermove 事件每秒触发数十次,用 state 会触发大量重渲染,ref 不触发渲染,绘制流畅度取决于设备性能而非 React 调度
useImperativeHandle 暴露的接口让 Studio 组件可以在凝练成画时导出涂鸦底稿:
useImperativeHandle(ref, () => ({
clear: () => {
const ctx = ctxRef.current
const canvas = canvasRef.current
if (!ctx || !canvas) return
const rect = canvas.getBoundingClientRect()
paintPaper(ctx, rect.width, rect.height)
setHasStrokes(false)
},
exportDataURL: () => {
const canvas = canvasRef.current
if (!canvas || !hasStrokes) return null
return canvas.toDataURL('image/png')
},
hasStrokes: () => hasStrokes
}))
5.4 凝练成画:从对话到画作的全链路
Studio 组件中的 handleConjure 是整个应用的高潮——把对话 + 涂鸦凝练成一幅画:
const handleConjure = useCallback(async () => {
if (generating) return
const sketch = canvasRef.current?.exportDataURL() ?? ''
if (messages.length < 2) {
addMessage({ role: 'assistant', text: '先和我聊聊你想画什么,再凝练成画吧。' })
avatar.speakText('先和我聊聊你想画什么,再凝练成画吧。')
return
}
setGenerating(true)
try {
const analysis = await analyzeArtwork(messages)
const imageUrl = generateArtworkImage(analysis)
const art: Artwork = {
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
createdAt: Date.now(),
sketch,
...analysis,
imageUrl,
conversation: messages.map((m) => `${m.role === 'user' ? '访客' : '墨白'}:${m.text}`).join('\n')
}
await saveArtwork(art)
setCurrentArtwork(art)
const speakText = `我为你凝练了一幅画,叫《${analysis.title}》。${analysis.caption}。`
avatar.speakText(speakText)
addMessage({ role: 'assistant', text: speakText })
} catch (err) {
console.error('凝练成画失败:', err)
avatar.speakText('这一次墨色未散,我们再画一笔试试。')
} finally {
setGenerating(false)
}
}, [generating, messages, addMessage, avatar, saveArtwork, setCurrentArtwork])
关键逻辑分析:
- sketch = canvasRef.current?.exportDataURL() 先从画板导出涂鸦底稿的 dataURL,存入 Artwork.sketch 字段——这样作品墙里既能看到 AI 成品图也能看到用户的原始涂鸦,两条创作轨迹并存
- analyzeArtwork(messages) 把整段对话发给 LLM,用 CANVAS_ARTIST_PROMPT 指令要求返回严格 JSON(style/mood/palette/imagery/title/caption),解析失败时兜底墨青默认色
- speakText 朗读凝练结果让数字人有"完成感":墨白会说出画作的标题和情绪注脚,这不仅是视觉输出更是具身交互智能的语音闭环——用户听到画被命名的那一刻,创作才真正结束
画作分析 prompt 与文生图 prompt 组装:
export const CANVAS_ARTIST_PROMPT = `你是一位温柔而有品味的"共画艺术伙伴"名叫墨白。你的工作,是帮助人们把一段说不清的念头,凝练成一幅可以留存的画。
访客会向你描述他此刻想画的东西——可能是一个场景、一段情绪、一个意象、或者只是几笔涂鸦的灵感。请认真倾听,然后用 JSON 的形式返回一份"画作分析报告"。
请严格按照以下 JSON 格式返回(不要返回 markdown 代码块,不要包含多余文字):
{
"style": "画风标签(英文,如:ink wash / watercolor / gouache / oil pastel / line art / ukiyo-e / minimalist)",
"mood": "情绪基调(英文,如:serene / melancholic / joyful / mysterious / nostalgic / hopeful)",
"palette": ["#xxxxxx", "#xxxxxx", "#xxxxxx"],
"imagery": "一段英文的画面描述,用于 AI 生成一幅画。要有具体的场景、光线、意象、氛围。风格参考:水墨 / 水彩 / 留白 / 极简,低饱和柔和色调",
"title": "一个中文标题(3-8字),诗意、有画面感",
"caption": "一句中文小诗(10-20字),作为这幅画的情绪注脚"
}
回应要求:
- palette 的 3 个颜色要能代表这幅画的主色调
- imagery 必须是英文,描述具体画面(不要抽象概念)
- title 与 caption 要克制、诗意,避免说教
- 只返回 JSON,不要包含任何前后缀或代码块符号`
export function buildImagePrompt(analysis: ArtworkAnalysis): string {
const colors = analysis.palette.join(', ')
const style = [
analysis.style || 'ink wash painting',
'soft watercolor',
'dreamy atmosphere',
'low saturation',
'gentle lighting',
'xuan paper texture',
'cinematic composition',
'no text, no signature'
].join(', ')
return `${analysis.imagery}. Style: ${style}. Dominant palette: ${colors}.`
}
- CANVAS_ARTIST_PROMPT 把 LLM 的角色从"聊天伙伴"切换到"画作分析师",要求输出结构化 JSON 而非自然语言——这是同一套数字人 SDK 在不同场景下的人格切换
- buildImagePrompt 在 LLM 输出的 imagery 基础上叠加了一组稳定美学关键词(xuan paper texture / low saturation / no text),保证文生图结果始终落在水墨水彩区间,不会跑偏成油画或 3D 渲染
5.5 数字人舞台:SDK canvas 居中与布局对齐
AvatarStage 是数字人渲染的视觉容器,需要解决 SDK 注入 canvas 后的三个排版问题:底部对齐、名牌溢出、canvas 偏右。
底部对齐——数字人 bottom 从 230px 调到 32px,与画板/对话舱的 padding 一致:
.avatar-stage {
position: fixed;
right: 14vh;
bottom: 32px;
width: 340px;
height: 80vh;
max-height: 720px;
z-index: 20;
pointer-events: none;
animation: fade-in 0.6s var(--ease) both;
}
名牌移入框内——top 从 -14px 改为 12px,名牌不再溢出 frame 上边界:
.avatar-stage__nameplate {
position: absolute;
top: 12px;
left: 50%;
transform: translateX(-50%);
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
z-index: 6;
pointer-events: none;
}
SDK canvas 居中——魔珐 SDK 给 canvas 设了 position: absolute + left 偏移 + transform: scale,导致数字人偏右,用三层覆盖解决:
.avatar-stage__sdk canvas {
left: 0 !important;
right: 0 !important;
margin-left: auto !important;
margin-right: auto !important;
transform-origin: center center !important;
}
关键逻辑分析:
- bottom: 32px 与 studio__body 的 padding: var(–space-lg)(即 32px)精确对齐,画板、对话舱、数字人三个框底部齐平,形成视觉上的"并排三栏"
- transform-origin: center center 是 canvas 居中的关键:SDK 的 scale(0.929) 默认以 0 0(top-left)为原点缩放,缩小后右边空出约 10px,改为以中心缩放后左右边距对称——配合 left:0; right:0; margin:auto 的 absolute 居中,canvas 最终左右边距均为 21.4px,差 0
- 窄屏(768px 以下)数字人缩为右上角 180px 小窗(top: 64px; bottom: auto),避免遮挡底部的对话舱输入框和凝练成画按钮
5.6 状态层:zustand + IndexedDB 作品墙
作品墙的持久化由 zustand + idb-keyval 协同完成:
export const useAppStore = create<AppState>((set, get) => ({
view: 'landing',
messages: [],
currentArtwork: null,
artworks: [],
generating: false,
setView: (view) => set({ view }),
addMessage: (msg) => set((s) => ({ messages: [...s.messages, msg] })),
setMessages: (msgs) => set({ messages: msgs }),
clearMessages: () => set({ messages: [] }),
setCurrentArtwork: (art) => set({ currentArtwork: art }),
setGenerating: (v) => set({ generating: v }),
loadArtworks: async () => {
const allKeys = await idbKeys()
const items: Artwork[] = []
for (const k of allKeys) {
if (String(k).startsWith(STORAGE_KEY)) {
const v = await idbGet(k as string)
if (v) items.push(v as Artwork)
}
}
items.sort((a, b) => b.createdAt - a.createdAt)
set({ artworks: items })
},
saveArtwork: async (art) => {
await idbSet(`${STORAGE_KEY}-${art.id}`, art)
set((s) => ({ artworks: [art, ...s.artworks.filter((a) => a.id !== art.id)] }))
},
deleteArtwork: async (id) => {
await idbDel(`${STORAGE_KEY}-${id}`)
set((s) => ({ artworks: s.artworks.filter((a) => a.id !== id) }))
}
}))
关键逻辑分析:
- idbKeys() 遍历所有 IndexedDB 键,用 startsWith(STORAGE_KEY) 过滤出本应用的作品——因为 idb-keyval 是全局共享的 key-value 存储,不加前缀会混入其他应用的数据
- saveArtwork 先 idbSet 写入 IndexedDB 再 set 更新内存,保证页面刷新后数据不丢;deleteArtwork 同理先 idbDel 再 set 过滤
- loadArtworks 按 createdAt 降序排序,最新凝练的画排在作品墙最前面
六、视觉设计系统:墨色画室
Collab Canvas 的视觉气质是东方画室的宁静与艺术感,由一组 CSS 令牌驱动:
:root {
--c-bg: #0e1418;
--c-bg-soft: #161e24;
--c-ink-blue: #5b8a9e;
--c-paper: #e8e0d0;
--c-cinnabar: #c8553c;
--c-gold: #c9a96e;
--c-text: #ece8de;
--c-text-muted: rgba(236, 232, 222, 0.55);
--c-text-soft: rgba(236, 232, 222, 0.75);
--c-border: rgba(91, 138, 158, 0.14);
--c-border-strong: rgba(201, 169, 110, 0.32);
--glass-bg: rgba(22, 30, 36, 0.6);
--glass-blur: 18px;
--glass-border: 1px solid rgba(201, 169, 110, 0.16);
--shadow-soft: 0 8px 32px rgba(0, 0, 0, 0.45);
--shadow-glow: 0 0 40px rgba(91, 138, 158, 0.22);
--r-sm: 8px; --r-md: 16px; --r-lg: 24px;
--space-xs: 8px; --space-sm: 12px; --space-md: 20px; --space-lg: 32px; --space-xl: 48px;
--ease: cubic-bezier(0.22, 1, 0.36, 1);
--ff-sans: 'Inter', 'PingFang SC', 'Microsoft YaHei', system-ui, sans-serif;
--ff-serif: 'Cormorant Garamond', 'Noto Serif SC', Georgia, serif;
}
背景由三层径向渐变叠加,模拟画室灯光的层次感:
body {
background:
radial-gradient(ellipse 60% 50% at 15% 12%, rgba(91, 138, 158, 0.18) 0%, transparent 60%),
radial-gradient(ellipse 50% 60% at 85% 18%, rgba(201, 169, 110, 0.12) 0%, transparent 60%),
radial-gradient(ellipse 70% 50% at 50% 100%, rgba(200, 85, 60, 0.08) 0%, transparent 70%),
var(--c-bg);
background-attachment: fixed;
}
- 深蓝基底 #0e1418 是画室的夜色,墨青 #5b8a9e 是主交互色(画笔默认色、按钮高亮),宣纸米 #e8e0d0 是画板底色和标题色,朱砂 #c8553c 是删除/清空按钮色,金赭 #c9a96e 是凝练成画 CTA 的渐变起点
- 三层径向渐变分别从左上(墨青光晕)、右上(金赭暖光)、底部(朱砂微光)投射,配合 background-attachment: fixed 让光晕不随滚动移动,营造画室灯光的稳定氛围
- 玻璃材质 .glass 用 18px 毛玻璃 + 130% 饱和度提升,让对话舱和数字人相框在深色背景上有"悬浮"感
七、总结
Collab Canvas 把具身交互智能嵌入了一条完整的创作链路:涂鸦 → 对话 → 凝练 → 成画 → 留存。数字人墨白不是锦上添花的装饰,而是贯穿全程的伙伴——她听你描述、用流式断句即时回应、在凝练时切换人格分析画作、最终用语音告诉你画的名字。
技术上,这套体验由三个关键设计支撑:
- 框架无关的数字人单例:AvatarController 以纯 TypeScript 类承载连接/断句/打断逻辑,useSyncExternalStore 桥接 React,保证核心能力可迁移、不随框架版本废弃
- 流式断句播报引擎:LLM 逐字返回、20 字门槛 + 标点检测截断、isStart/isEnd 标记串行队列,让数字人边生成边说、不抢话、可随时打断
- 双层 prompt 架构:COMPANION_PROMPT 驱动对话舱的温柔伙伴人格,CANVAS_ARTIST_PROMPT 驱动凝练成画的结构化分析,同一套 SDK 在两个场景间无缝切换
这条链路的本质是具身交互智能——AI 不只是生成图片的工具,而是有形象、有语音、有肢体表达的创作伙伴,把语言能力与视觉创作绑定在同一次交互里。当用户听到墨白说出"我为你凝练了一幅画,叫《此刻》"的那一刻,创作才真正闭环。
魔珐星云 PC 端官方链接:https://xingyun3d.com?utm_campaign=daily&utm_source=CSDNwanfen2&utm_medium=&utm_term=&utm_content=
更多推荐
所有评论(0)