一位站在金色舞台上的解说员纪燃,能听、能说、有肢体表达——这正是具身交互智能的核心。本文从魔珐星云控制台配置到原生 JS 全栈工程源码,拆解 Seed Chronicle 如何以参数流 SDK 为中枢,让数字人嵌入选球星→写档案→生插画→翻卡片→存画廊的典藏全链路。

魔珐星云 PC 端官方链接https://xingyun3d.com?utm_campaign=daily&utm_source=CSDNwanfen2&utm_medium=&utm_term=&utm_content=


一、产品概览:当数字人成为典藏解说员

Seed Chronicle(世界杯典藏回忆卡)是一个把数字人嵌入典藏叙事流程的 Web 应用。用户打开页面,数字人解说员纪燃便以金色舞台形式常驻右侧、自动语音问候;在 AI 工作台填写球员信息或观赛记忆后,豆包 Seed 模型一次性吃下整届赛事上下文(1M+ tokens),生成编年史文案、关键数据与名场面解读,纪燃随即语音播报生成结果;用户翻转 3D 卡片查看背面档案,存入画廊时纪燃语音确认珍藏。整条链路不是"输入球员名→生成一张图"的简单工具流,而是一段有温度的典藏对话。核心能力包括:

  • 球星典藏卡与个人回忆卡双模式,四种卡面风格(复古贴纸 / EA FC 金卡 / 全息镭射 / 决赛限定)
  • 豆包 Seed 1M+ 超长上下文,整届赛事数据一次性注入,模型拥有完整全局视野
  • Seed 文生图模型同步生成卡面插画,铺到 3D 卡片正面
  • 数字人纪燃常驻右侧舞台,连接问候、生成播报、保存确认,全程语音陪伴
  • 3D 卡片支持鼠标跟随倾斜、点击翻转、镭射反光,配合 html2canvas 高清 PNG 导出

在这条链路中,数字人纪燃不是页面角落的装饰动画——她是具身交互智能的载体:有形象、有声音、有肢体表达,在每一个关键节点用语音与用户对话。这区别于传统的图文生成工具,把大语言模型的内容能力、文生图的视觉能力与数字人的具身表达能力绑定在同一次交互里。


二、技术架构总览

项目采用纯原生 HTML + CSS + JavaScript 技术栈(零框架),配合 Python 本地服务器做 API 代理,整体分为四层:

层级 职责 核心文件
视图层 画廊 / AI 工作台 / 设置弹层 / 数字人舞台 index.html
逻辑层 状态管理 / Seed 调用 / 3D 卡片交互 / 导出 app.js
数字人层 SDK 连接 / 语音播报 / 字幕 / 打断 avatar.js
服务层 静态文件托管 + Seed API 反代(绕过 CORS) server.py

数字人逻辑以 IIFE 封装的原生 JS 单例承载,通过 window.AvatarService 暴露全局接口。这意味着 app.js 中的任何模块都可以在生成卡片、保存画廊时调用纪燃的语音能力——不需要框架的状态管理,不需要构建工具,三个 script 标签即可串联整条具身交互智能链路。


三、前提准备

魔珐星云控制台配置

数字人纪燃的形象、场景、音色、表演均由魔珐星云控制台驱动。以下五步完成驱动应用创建与参数配置,是代码层接入 SDK 前的必要前提。

步骤1:魔珐星云控制台创建驱动应用

在魔珐星云控制台创建一个新的驱动应用,获取 appId 与 appSecret,这两个密钥将填入项目 avatar.js 的 AVATAR_CONFIG。

步骤2:形象配置

为数字人纪燃选择或上传形象资源,配置外观参数。形象配置决定了 SDK 渲染时的角色外观——在典藏回忆卡场景中,纪燃作为世界杯解说员出镜,形象需要匹配金色运动风的视觉调性。这是具身交互智能中"有形象"这一要素的直接来源。

步骤3:场景配置

配置数字人所在的虚拟场景,包括灯光、背景、机位等。场景配置与前端 avatar-stage 容器配合,决定纪燃在页面右侧舞台中的视觉呈现方式。

步骤4:音色配置

为纪燃选择解说员音色,控制语音合成的语速与情感倾向。音色配置直接影响播报卡片生成结果时的听感——热血、凝练、有电影感,是具身交互智能中"能说"这一要素的质量基础。

步骤5:表演配置

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


四、核心代码讲解

4.1 数字人服务:原生 JS 版魔珐 SDK 集成

avatar.js 是整个具身交互智能链路的视觉与语音中枢。它用 IIFE 封装,通过 window.AvatarService 暴露全局接口,管理 SDK 连接、语音播报、字幕同步与实时打断:

(() => {
  'use strict';

  /* ============== 配置 ============== */
  const AVATAR_CONFIG = {
    appId: 'your_avatar_app_id',
    appSecret: 'your_avatar_app_secret',
    gatewayUrl: 'https://nebula-agent.xingyun3d.com/user/v1/ttsa/session',
    dataSource: '2',
    customId: 'demo',
  };

  const AVATAR_IDENTITY = {
    name: '纪燃',
    role: '世界杯典藏解说员',
    greeting: '欢迎来到世界杯典藏回忆卡。我是你的解说员纪燃,帮你把整届赛事、十几年生涯、那些熬夜与呐喊,凝成一张可翻转、可珍藏的典藏卡。',
  };

  /* ============== 状态 ============== */
  const avatarState = {
    connected: false,
    connecting: false,
    instance: null,
    subtitle: '',
    avatarState: '',
    listeners: new Set(),
  };

  /* ============== 订阅机制 ============== */
  function subscribe(listener) {
    avatarState.listeners.add(listener);
    return () => avatarState.listeners.delete(listener);
  }

  function notify() {
    avatarState.listeners.forEach(l => l());
  }

  function patch(partial) {
    Object.assign(avatarState, partial);
    notify();
  }

  /* ============== DOM 引用 ============== */
  const $ = (sel) => document.querySelector(sel);
  const containerId = 'xmov-avatar-container';

  function getSubtitleEl() {
    return $('#avatar-subtitle');
  }

  function getLoadingEl() {
    return $('#avatar-loading');
  }

  function getLoadingTextEl() {
    return $('#avatar-loading-text');
  }

  /* ============== 连接 ============== */
  async function connect() {
    if (avatarState.connected || avatarState.connecting) return;

    const containerEl = document.getElementById(containerId);
    if (!containerEl) {
      console.error('[Avatar] 容器不存在:', containerId);
      return;
    }

    patch({ connecting: true });
    updateLoadingUI(true);

    try {
      const url = new URL(AVATAR_CONFIG.gatewayUrl);
      url.searchParams.append('data_source', AVATAR_CONFIG.dataSource);
      url.searchParams.append('custom_id', AVATAR_CONFIG.customId);

      let resolveConnect;
      let rejectConnect;
      const connectPromise = new Promise((res, rej) => {
        resolveConnect = res;
        rejectConnect = rej;
      });

      const options = {
        containerId: `#${containerId}`,
        appId: AVATAR_CONFIG.appId,
        appSecret: AVATAR_CONFIG.appSecret,
        enableDebugger: false,
        gatewayServer: url.toString(),
        onProxyWidgetEvent: (event) => console.log('[Avatar SDK事件]', event),
        onStateChange: (state) => {
          patch({ avatarState: state });
        },
        onMessage: async (error) => {
          // 连接失败时 reject
          if (!avatarState.connected) {
            rejectConnect(new Error(error.message || 'SDK连接失败'));
          }
        },
        onVoiceStateChange: (status) => {
          if (status.includes('end')) {
            patch({ avatarState: 'interactive_idle' });
          }
        },
      };

      // 创建 SDK 实例
      const avatar = new window.XmovAvatar(options);

      // 等待初始化
      await new Promise(r => setTimeout(r, 3000));

      await avatar.init({
        onDownloadProgress: (progress) => {
          if (progress >= 100) {
            resolveConnect(true);
          }
        },
        onClose: () => {
          patch({ avatarState: '', connected: false });
        },
      });

      // 等待连接完成或超时
      const timeout = new Promise((_, rej) => setTimeout(() => rej(new Error('连接超时')), 15000));
      try {
        await Promise.race([connectPromise, timeout]);
      } catch (e) {
        console.warn('[Avatar] 连接等待结束:', e.message);
      }

      avatarState.instance = avatar;
      patch({ connected: true, connecting: false });
      updateLoadingUI(false);
      console.log('[Avatar] 连接成功');

      // 连接成功后播报问候
      setTimeout(() => {
        speakText(AVATAR_IDENTITY.greeting);
      }, 800);

    } catch (err) {
      console.error('[Avatar] 连接失败:', err);
      patch({ connected: false, connecting: false });
      updateLoadingUI(false);
    }
  }

  function disconnect() {
    if (avatarState.instance) {
      try {
        avatarState.instance.stop();
        avatarState.instance.destroy();
      } catch (e) {
        console.error('[Avatar] 断开失败:', e);
      }
      avatarState.instance = null;
      patch({ connected: false, avatarState: '' });
    }
  }

  /* ============== 播报 ============== */
  function speakText(text) {
    if (!avatarState.instance || !text) return;
    // 生成 SSML
    const ssml = `<speak>${text}</speak>`;
    avatarState.instance.speak(ssml, true, true);
    patch({ avatarState: 'speak' });
  }

  function interrupt() {
    if (!avatarState.instance) return;
    try {
      if (typeof avatarState.instance.interactiveidle === 'function') {
        avatarState.instance.interactiveidle();
      } else if (typeof avatarState.instance.interrupt === 'function') {
        avatarState.instance.interrupt();
      }
      patch({ avatarState: 'interactive_idle' });
    } catch (e) {
      console.error('[Avatar] 打断失败:', e);
    }
  }

  /* ============== 字幕 ============== */
  function setSubtitle(text) {
    patch({ subtitle: text });
    const el = getSubtitleEl();
    if (el) {
      el.textContent = text;
      el.classList.toggle('is-on', !!text);
    }
  }

  /* ============== UI 更新 ============== */
  function updateLoadingUI(show) {
    const el = getLoadingEl();
    if (el) {
      el.style.display = show ? 'flex' : 'none';
    }
  }

  /* ============== 初始化 ============== */
  function init() {
    console.log('[Avatar] 初始化数字人服务');
    connect();
  }

  // DOM 加载完成后初始化
  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', init);
  } else {
    init();
  }

  /* ============== 暴露全局接口 ============== */
  window.AvatarService = {
    connect,
    disconnect,
    speakText,
    interrupt,
    setSubtitle,
    subscribe,
    getState: () => ({
      connected: avatarState.connected,
      connecting: avatarState.connecting,
      subtitle: avatarState.subtitle,
      avatarState: avatarState.avatarState,
    }),
    IDENTITY: AVATAR_IDENTITY,
  };

})();

关键逻辑分析:

  • IIFE 封装避免污染全局命名空间,window.AvatarService 暴露 connect/speakText/interrupt 等方法。app.js 在生成卡片后通过 window.AvatarService.speakText 让纪燃播报结果——这是具身交互智能在零框架场景下的落地方案
  • AVATAR_IDENTITY 定义纪燃的身份与问候语,贯穿页面名牌、连接问候、播报文案。connect 成功后延迟 800ms 播报问候,给魔珐 SDK 一个稳定窗口,避免初始化与播报抢资源——这与 Digital Garden 中 GardenGuide 组件的 greeted ref 设计同源
  • subscribe + patch 实现发布-订阅模式。avatarState 变化时通知所有监听者,这与 React 项目中 useSyncExternalStore 的桥接逻辑同源——是具身交互智能跨框架复用的架构基础
  • speakText 将纯文本包裹为 SSML 格式发送给魔珐参数流 SDK,SDK 驱动数字人发音并同步肢体动作。speak 的第二个参数 true 表示打断当前播报,第三个参数 true 表示启用表情动画——让纪燃在说话时有自然的嘴唇同步与面部表情
  • onVoiceStateChange 回调在语音播放结束时将状态切回 interactive_idle,让数字人回到待机姿态,而非停留在说话姿势——这是具身交互智能中"有肢体表达"的细节
  • onMessage 回调在 SDK 连接失败时 reject connectPromise,配合 15 秒超时兜底,防止无限等待。连接失败不阻塞用户使用,这是具身交互智能的容错设计

4.2 数字人播报集成:关键节点的语音反馈

app.js 在两个关键节点调用纪燃的语音能力,让具身交互智能贯穿用户旅程:

// 生成卡片成功后(app.js onGenerate 函数末尾)
if (window.AvatarService && window.AvatarService.getState().connected) {
  const announceText = card.type === 'player'
    ? `已为${card.playerName}生成典藏卡,${card.content.title}`
    : `已生成回忆卡,${card.content.title}`;
  window.AvatarService.speakText(announceText);
}

// 保存画廊后(app.js saveToGallery 函数)
if (window.AvatarService && window.AvatarService.getState().connected) {
  window.AvatarService.speakText('已为你珍藏这张典藏卡。');
}

关键逻辑分析:

  • 先检查 window.AvatarService 是否存在且 connected 为 true,再根据卡片类型构建播报文案——这是具身交互智能的优雅降级设计:数字人在线时语音播报,离线时静默跳过,不阻塞主流程
  • 纪燃说出"已为梅西生成典藏卡,潘帕斯雄鹰·梅西"的那一刻,大模型的内容能力与数字人的具身表达能力完成了闭环。用户不只是看到一张卡片,而是听到一位解说员为这张卡片命名——这是具身交互智能赋予典藏的温度
  • saveToGallery 中的"已为你珍藏这张典藏卡"是整条链路的收尾语音,配合卡片存入画廊的动画,让保存动作有了仪式感

4.3 Seed 调用与降级策略:整届赛事上下文一次性注入

app.js 中的 callSeed 函数是内容引擎的核心。它负责把整届赛事上下文 + 球员/观赛信息组装成 prompt,调用 Seed 模型获取 JSON 格式的卡片档案,为纪燃的语音播报提供内容素材:

async function callSeed(messages, apiKey) {
  if (!apiKey) {
    // 未填 Key:直接走本地 demo,避免无意义 fetch
    const last = messages[messages.length - 1].content;
    const isPlayer = last.includes('请为球员');
    const payload = isPlayer
      ? extractPlayerFields(last)
      : extractMemoryFields(last);
    return JSON.stringify(mockGenerate(isPlayer ? 'player' : 'memory', payload));
  }
  try {
    const res = await fetch(SEED_ENDPOINT, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'Authorization': `Bearer ${apiKey}`,
      },
      body: JSON.stringify({
        model: SEED_MODEL,
        messages,
        temperature: 0.7,
        // Seed 1M+ 超长上下文:此处一次性把整届赛事 + 球员生涯全部喂入
        // 依赖豆包 Seed-evolving 的长上下文能力,无需分段
        response_format: { type: 'json_object' },
      }),
    });
    if (!res.ok) {
      const errText = await res.text().catch(() => '');
      throw new Error(`Seed 接口错误 ${res.status}: ${errText.slice(0, 200)}`);
    }
    const data = await res.json();
    return data.choices?.[0]?.message?.content ?? '';
  } catch (err) {
    // CORS / 网络错误:降级到本地 mock,保证 Demo 可用
    console.warn('[Seed] 调用失败,已切换到本地 Demo 模式:', err.message);
    const last = messages[messages.length - 1].content;
    const isPlayer = last.includes('请为球员');
    const payload = isPlayer ? extractPlayerFields(last) : extractMemoryFields(last);
    // 在文本中附加提示,让 UI 知道当前是 Demo 数据
    const mockData = mockGenerate(isPlayer ? 'player' : 'memory', payload);
    mockData.__demo = true;
    mockData.__demoReason = err.message;
    return JSON.stringify(mockData);
  }
}

关键逻辑分析:

  • SEED_ENDPOINT 指向 /api/seed/chat/completions,由 server.py 代理转发到火山方舟。前端不直接请求外部域名,完全绕过 CORS
  • 未填 apiKey 时直接走 mockGenerate 本地降级,保证双击 HTML 即可体验完整 UI 流程——这是活动页 Demo 的刚需
  • fetch 失败时同样降级到本地 mock,并附加 __demo 标记让 UI 显示提示。用户不会因为接口问题卡在某一步
  • response_format 设为 json_object 强制模型输出 JSON,避免解析 markdown 包裹的额外复杂度。模型返回的 JSON 经 normalizeCardData 做长度兜底后,同时送给卡面渲染和纪燃语音播报

4.4 Prompt 工程:编年史官人格与字数硬约束

buildPlayerMessages 函数构建球员卡的 system + user prompt,是具身交互智能内容层的核心。它需要同时完成三件事:注入整届赛事全局上下文、定义编年史官人格、约束输出字数:

function buildPlayerMessages(playerName, country) {
  const ctx = state.tournamentContext || '(用户未提供完整赛事上下文,请基于你对世界杯的公开记忆进行补全)';
  return [
    {
      role: 'system',
      content:
        '你是世界杯编年史官,专精于把整届赛事、球员十几年生涯浓缩为球星卡背面档案。' +
        '你必须严格输出 JSON,且字段与用户指定一致。文字热血、凝练、有电影感,数字精准。' +
        '【硬性长度限制】title ≤ 14 个汉字(含绰号和间隔符);chronicle 严格 120-140 字;' +
        'keyStats 共 3 条,每条 ≤ 14 字;legendaryMoment 严格 70-90 字。超长会被截断,请严格遵守。',
    },
    {
      role: 'user',
      content:
`【整届赛事全局数据】
${ctx}

请为球员「${playerName}${country ? `(所属国家队:${country}` : ''}输出一张典藏球星卡背面档案。
要求输出严格的 JSON(所有文本字段必须严格遵守上面的字数上限):
{
  "title": "卡面标题,含绰号,总共 ≤14 个汉字,如『潘帕斯雄鹰·梅西』",
  "chronicle": "120-140 字编年史",
  "keyStats": ["3 条,每条 ≤14 字"],
  "legendaryMoment": "70-90 字名场面解读",
  "rarity": "common|silver|gold|black|final",
  "rating": "数字 75-99",
  "position": "位置缩写 FW/MF/DF/GK",
  "jerseyNumber": "球衣号码"
}`,
    },
  ];
}

关键逻辑分析:

  • system prompt 中的"编年史官"人格定义了文案风格:热血、凝练、有电影感。这不是随便写的形容词——它直接决定了模型输出的文字质感,也是纪燃语音播报时的文案来源。文字有温度,纪燃说出来才有感染力
  • 硬性长度限制(title ≤ 14 字、chronicle 120-140 字等)是卡面物理空间决定的。3D 卡片背面区域有限,超长文字会溢出或截断。prompt 层约束 + 代码层 clampText 双重兜底
  • 整届赛事全局上下文通过 state.tournamentContext 注入,这是 Seed 1M+ 上下文能力的直接应用——传统模型需要分段摘要的场景,这里一次性喂入,让模型在生成编年史时拥有完整的全局视野

4.5 3D 卡片交互:鼠标跟随倾斜与翻转

bindCardInteractions 函数为每张卡片绑定鼠标跟随倾斜和点击翻转效果,是纪燃语音之外的视觉体验核心:

function bindCardInteractions(card3d) {
  if (!card3d) return;
  const scene = card3d.parentElement;
  let flipped = false, dragging = false, startX = 0, startY = 0;
  // 标记是否来自 touch,避免移动端 touch 结束后 click 事件二次触发
  let touchJustEnded = false;

  const applyTilt = (e) => {
    if (flipped) return;
    const r = scene.getBoundingClientRect();
    const x = (e.clientX - r.left) / r.width;
    const y = (e.clientY - r.top) / r.height;
    const rotY = (x - 0.5) * 25;
    const rotX = (0.5 - y) * 18;
    card3d.style.setProperty('--mx', `${x * 100}%`);
    card3d.style.setProperty('--my', `${y * 100}%`);
    card3d.style.setProperty('--holo-angle', `${x * 360}deg`);
    // 叠加小角度倾斜(注意:不覆盖 .flipped 的 rotateY(180deg))
    card3d.style.transform = `rotateY(${rotY}deg) rotateX(${rotX}deg)`;
  };
  const resetTilt = () => {
    if (!flipped) card3d.style.transform = '';
  };
  const toggleFlip = () => {
    flipped = !flipped;
    card3d.classList.toggle('flipped', flipped);
    // 关键:清除内联 transform,让 CSS 类全权控制翻转状态;回正时保留过渡
    card3d.style.transform = '';
  };

  scene.addEventListener('mousemove', (e) => { if (!touchJustEnded) applyTilt(e); });
  scene.addEventListener('mouseleave', resetTilt);
  scene.addEventListener('click', (ev) => {
    if (touchJustEnded) { touchJustEnded = false; return; }
    // 点击到操作按钮,不触发翻转
    if (ev.target.closest('.card-action-btn')) return;
    toggleFlip();
  });

  // 移动端触摸翻转
  scene.addEventListener('touchstart', (e) => {
    dragging = true;
    startX = e.touches[0].clientX;
    startY = e.touches[0].clientY;
  }, { passive: true });
  scene.addEventListener('touchend', (e) => {
    const dx = e.changedTouches[0].clientX - startX;
    const dy = e.changedTouches[0].clientY - startY;
    if (Math.abs(dx) < 10 && Math.abs(dy) < 10) {
      touchJustEnded = true;
      toggleFlip();
    }
    dragging = false;
  });
}

关键逻辑分析:

  • applyTilt 通过鼠标在卡片区域内的相对位置计算倾斜角度,最大旋转 25 度(Y 轴)和 18 度(X 轴),产生自然的 3D 跟随效果
  • –mx / --my CSS 变量驱动镭射反光层的位置,–holo-angle 驱动全息层的旋转角度——鼠标移动时卡面产生真实的光线折射变化
  • toggleFlip 翻转时清除内联 transform,让 CSS 类 .flipped 全权控制 rotateY(180deg)。这避免了内联样式与 CSS 类的优先级冲突
  • touchJustEnded 标记解决移动端触摸结束后 click 事件二次触发的问题——这是移动端适配的常见陷阱
  • touchstart/touchend 事件实现移动端触摸翻转,通过判断移动距离小于 10px 来区分点击与滑动

4.6 API 代理服务器:107 行 Python 解决 CORS

server.py 是整个项目的服务端基础设施,用 Python 标准库实现静态文件托管和 API 反向代理,为具身交互智能链路提供稳定的后端支撑:

class Handler(http.server.SimpleHTTPRequestHandler):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, directory=STATIC_DIR, **kwargs)

    def end_headers(self):
        # CORS 头(允许浏览器跨域取字体等资源,冗余保险)
        self.send_header("Access-Control-Allow-Origin", "*")
        self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
        self.send_header("Access-Control-Allow-Headers", "Content-Type, Authorization")
        super().end_headers()

    def do_OPTIONS(self):
        self.send_response(200)
        self.end_headers()

    def do_POST(self):
        if self.path.startswith("/api/seed/"):
            self.proxy_to_seed(SEED_BASE, "/api/seed/")
        elif self.path.startswith("/api/image/"):
            self.proxy_to_seed(SEED_IMAGE_BASE, "/api/image/")
        else:
            self.send_error(404, "Not Found")

    def proxy_to_seed(self, upstream_base, prefix):
        upstream_path = self.path[len(prefix):]
        upstream_url = f"{upstream_base}/{upstream_path}"

        length = int(self.headers.get("Content-Length", "0"))
        body = self.rfile.read(length) if length else b""
        auth = self.headers.get("Authorization", "")

        req = urllib.request.Request(
            upstream_url,
            data=body,
            method="POST",
            headers={
                "Content-Type": "application/json",
                "Authorization": auth,
            },
        )
        try:
            print(f"[PROXY] POST {upstream_url}  body_len={len(body)}", flush=True)
            with urllib.request.urlopen(req, timeout=180) as resp:
                data = resp.read()
                self.send_response(resp.status)
                self.send_header("Content-Type", resp.headers.get("Content-Type", "application/json; charset=utf-8"))
                self.send_header("Content-Length", str(len(data)))
                self.send_header("Access-Control-Allow-Origin", "*")
                self.end_headers()
                self.wfile.write(data)
                print(f"[PROXY] <- {resp.status}  {len(data)} bytes", flush=True)
        except urllib.error.HTTPError as e:
            err_body = e.read()
            print(f"[PROXY] !! HTTP {e.code}: {err_body[:300]!r}", flush=True)
            self.send_response(e.code)
            self.send_header("Content-Type", "application/json; charset=utf-8")
            self.send_header("Access-Control-Allow-Origin", "*")
            self.end_headers()
            self.wfile.write(err_body)
        except Exception as e:
            print(f"[PROXY] !! EXC {type(e).__name__}: {e}", flush=True)
            msg = json.dumps({"error": str(e)}).encode("utf-8")
            self.send_response(502)
            self.send_header("Content-Type", "application/json; charset=utf-8")
            self.send_header("Access-Control-Allow-Origin", "*")
            self.end_headers()
            self.wfile.write(msg)

关键逻辑分析:

  • /api/seed/* 转发到火山方舟 chat/completions 接口,/api/image/* 转发到 Seedream 文生图接口。两条路径共用同一个 proxy_to_seed 方法
  • Authorization 头原样透传给火山方舟,API Key 存储在浏览器 localStorage,不经过 server.py 保存——密钥管理完全在前端
  • 180 秒超时覆盖 Seed 模型生成长文案的耗时,Access-Control-Allow-Origin: * 让浏览器端的 fetch 请求不被 CORS 拦截
  • end_headers 方法统一添加 CORS 头,do_OPTIONS 处理预检请求,proxy_to_seed 包含完整的异常处理(HTTPError 和通用 Exception)

五、视觉设计系统:金色球场

Seed Chronicle 的视觉气质是世界杯的金色荣耀与球场绿茵的沉浸感,由一组 CSS 令牌驱动:

:root {
  --bg-0: #05070f;
  --bg-1: #0a0e1a;
  --bg-2: #111828;
  --ink-0: #f4efe3;
  --ink-1: #c9c2b0;
  --ink-2: #8b8676;
  --ink-3: #5a5647;
  --gold-0: #ffe9a0;
  --gold-1: #d4af37;
  --gold-2: #8a6a1a;
  --gold-3: #4a3a12;
  --red: #e4002b;
  --green-pitch: #0f4d2a;
  --green-glow: #1db954;

  --rarity-common: #9aa4b2;
  --rarity-silver: #d8dde6;
  --rarity-gold: #f5c542;
  --rarity-black: #2a2a2a;
  --rarity-final: linear-gradient(135deg, #ff4d4d, #ffb800, #ffffff);

  --card-w: 340px;
  --card-h: 480px;
  --radius: 14px;
  --shadow-lg: 0 30px 80px -20px rgba(0, 0, 0, 0.8), 0 10px 30px -10px rgba(212, 175, 55, 0.15);

  --font-display: 'Bebas Neue', 'Oswald', sans-serif;
  --font-head: 'Oswald', 'Noto Sans SC', sans-serif;
  --font-body: 'Noto Sans SC', system-ui, sans-serif;
  --font-mono: 'JetBrains Mono', monospace;
}

背景由四层元素叠加,营造球场灯光的层次感:

.bg-layer {
  position: fixed; inset: 0; z-index: -1; overflow: hidden;
  background: radial-gradient(ellipse at top, #15213a 0%, var(--bg-0) 60%);
}
.bg-gradient {
  position: absolute; inset: 0;
  background:
    radial-gradient(circle at 20% 10%, rgba(228, 0, 43, 0.18), transparent 50%),
    radial-gradient(circle at 80% 0%, rgba(212, 175, 55, 0.18), transparent 50%),
    radial-gradient(circle at 50% 100%, rgba(15, 77, 42, 0.35), transparent 60%);
}
.bg-grid {
  position: absolute; inset: 0;
  background-image:
    linear-gradient(rgba(212, 175, 55, 0.05) 1px, transparent 1px),
    linear-gradient(90deg, rgba(212, 175, 55, 0.05) 1px, transparent 1px);
  background-size: 48px 48px;
  mask-image: radial-gradient(ellipse at center, black 30%, transparent 75%);
}
  • 深色基底 #05070f 是球场夜色,金色 #d4af37 是主交互色(按钮、稀有度标记、镭射反光),红色 #e4002b 是决赛限定的点缀色,绿茵 #0f4d2a 是画廊区域的底色暗示
  • 三层径向渐变分别从左上(红色微光)、右上(金色暖光)、底部(绿茵光)投射,配合 48px 间距的金色网格线(mask-image 渐隐边缘)模拟球场的灯光与草皮纹理
  • 奖杯光晕 .bg-trophy-glow 用 800px 的金色径向渐变 + 6 秒脉冲动画,在页面顶部中央产生呼吸感的荣耀光效
  • 数字人舞台 .avatar-stage 用金色玻璃态外框(backdrop-filter: blur(16px) + 金色描边)让纪燃在右侧舞台上有"悬浮"感,与整体金色球场视觉统一

六、总结

Seed Chronicle 把具身交互智能嵌入了一条完整的典藏链路:选球星 → 写档案 → 生插画 → 翻卡片 → 存画廊。数字人纪燃不是锦上添花的装饰,而是贯穿全程的解说员——她自动迎接访客、在卡片生成后播报结果、在保存画廊时语音确认。

技术上,这套体验由三个关键设计支撑:

  1. 魔珐参数流 SDK 集成:IIFE 封装的原生 JS 数字人服务,通过 window.AvatarService 暴露全局接口,connect/speakText/interrupt 三个核心方法覆盖连接、播报、打断全链路——这是具身交互智能在零框架场景的落地方案
  2. Seed 1M+ 超长上下文:整届赛事数据一次性注入 prompt,模型在生成编年史时拥有完整全局视野。文案经纪燃语音播报,让大模型的内容能力与数字人的具身表达能力形成闭环
  3. CSS 3D 卡片系统:鼠标跟随倾斜 + 点击翻转 + 镭射反光 + 四种卡面风格,配合 html2canvas 高清导出,让数字内容可以沉淀为可分享的实体

这条链路的本质是具身交互智能——AI 不只是生成文案的工具,而是有形象、有声音、有肢体表达的解说员,把大语言模型的内容能力、文生图的视觉能力与数字人的具身表达能力绑定在同一次交互里。当用户听到纪燃说出"已为梅西生成典藏卡,潘帕斯雄鹰·梅西"的那一刻,典藏才真正有了温度。

魔珐星云 PC 端官方链接https://xingyun3d.com?utm_campaign=daily&utm_source=CSDNwanfen2&utm_medium=&utm_term=&utm_content=

Logo

电影级数字人,免显卡端渲染SDK,十行代码即可调用,工业级demo免费开源下载!

更多推荐