智剧通(ZJT)文档中心 对话组节点实现

对话组节点实现文档

概述

为视频工作流实现了全新的对话组节点,可以根据分镜中的对话信息自动匹配角色库中的参考音频,批量生成对话语音。

功能特性

核心功能

  1. 自动连接分镜节点:从分镜节点获取对话数据(dialogue字段)
  2. 智能角色匹配:根据角色名称自动匹配当前世界的角色库
  3. 参考音频管理
  1. 情感控制:支持三种情感控制方式(与参考音频相同、使用情感参考音频、使用情感向量)
  2. 视频连接:支持从视频节点连接作为情感参考来源
  3. 批量生成:支持单个生成和批量生成全部对话音频
  4. 音频管理:支持在线播放和下载生成的音频
  5. 对话编辑:支持编辑对话的角色名和内容,修改后自动清除已生成的音频
  6. 对话删除:支持删除单条对话,自动调整音频结果索引
  7. 国际化 (i18n):所有用户界面文本均支持国际化,通过 window.t() 函数翻译

节点显示方式

对话组
├─ 对话列表:
│  ├─ 【陈峰】 [编辑] [删除] [生成音频]
│  │  └─ "龙国有救了……"
│  │  └─ [播放器] [下载] [添加到时间轴]
│  └─ 【裁判】 [编辑] [删除] [生成音频]
│     └─ "龙国,胜!倭国挑战失败!"
│     └─ [播放器] [下载] [添加到时间轴]
├─ 情感控制方式: [与参考音频相同 ▼]
├─ (当选择"使用情感参考音频"时)
│  ├─ 情感参考音频: [文件选择] [清除]
│  ├─ 情感权重: [0.0 - 1.6]
│  └─ 视频输入端口 (可连接视频节点)
├─ (当选择"使用情感向量"时)
│  └─ 8种情感滑块: 喜/怒/哀/惧/厌恶/低落/惊喜/平静 (总和≤1.5)
├─ 参考音频: [添加音频]
│  ├─ 最多6个音频,每个不超过20秒、10MB
│  ├─ 我判 [播放器] [删除]
│  └─ 龙国观众甲 [播放器] [删除]
└─ [生成全部]

技术实现

涉及模块

数据流程

1. 连接分镜节点

// 从分镜节点获取对话数据
const shotJson = fromNode.data.shotJson;
if(shotJson && shotJson.dialogue){
  node.data.dialogues = shotJson.dialogue;
}

2. 对话数据结构

{
  "dialogues": [
    {
      "character_id": "char_003",
      "character_name": "【【陈峰】】",
      "text": "龙国有救了……"
    }
  ],
  "audioResults": {
    "0": {
      "audioUrl": "blob:http://..."
    }
  }
}

3. 参考音频匹配逻辑(优先级)

系统按以下优先级查找参考音频:

// 标准化角色名称:去除【】等特殊符号
const normalizeCharacterName = (name) => {
  if(!name) return '';
  return name.replace(/[【】\[\]]/g, '').trim();
};

const normalizedCharacterName = normalizeCharacterName(characterName);
let refAudioFound = false;

// 优先级1: 对话组节点的参考音频
if(node.data.referenceAudios && node.data.referenceAudios.length > 0){
  const matchedRefAudio = node.data.referenceAudios.find(
    audio => normalizeCharacterName(audio.characterName) === normalizedCharacterName
  );

  if(matchedRefAudio){
    const voiceBlob = await fetch(matchedRefAudio.url).then(r => r.blob());
    form.append('ref_audio', voiceBlob, 'ref_audio.wav');
    refAudioFound = true;
  }
}

// 优先级2: 角色库的参考音频
if(!refAudioFound){
  const matchedCharacter = await fetchAndMatchCharacter(worldId, characterName);

  if(matchedCharacter && matchedCharacter.default_voice){
    const voiceBlob = await fetch(matchedCharacter.default_voice).then(r => r.blob());
    form.append('ref_audio', voiceBlob, 'ref_audio.wav');
    refAudioFound = true;
  }
}

// 如果都没找到,抛出错误
if(!refAudioFound){
  throw new Error(`角色"${characterName}"没有配置参考音频`);
}

匹配规则

API接口

使用的API

  1. GET /api/characters?world_id={worldId} - 获取世界角色列表
  2. POST /api/audio-generate - 生成音频
  3. GET /api/audio-status/{audioId} - 查询音频生成状态

角色数据结构

{
  id: 1,
  name: "陈峰",
  default_voice: "http://example.com/audio.wav",  // 参考音频URL
  world_id: 1,
  // ... 其他字段
}

使用流程

  1. 创建对话组节点
  1. 连接分镜节点
  1. 编辑对话(可选)
  1. 删除对话(可选)
  1. 生成音频
  1. 播放和下载

角色匹配规则

  1. 优先级1:对话组节点参考音频 - 在 referenceAudios 中按角色名匹配
  2. 优先级2:角色库参考音频 - 通过 fetchAndMatchCharacter() 从角色库查找
  3. 去符号匹配:自动去除【】[] 等符号后再匹配
  4. 无匹配时:生成失败,提示错误 "角色XXX没有配置参考音频"

示例:

参考音频功能详解

功能概述

对话组节点支持直接上传参考音频,无需在角色库中预先配置。这对于临时角色、未知角色或需要特定音色的场景非常有用。

使用方法

  1. 添加参考音频
  1. 音频限制
  1. 角色名称匹配
  1. 删除参考音频

数据结构

node.data.referenceAudios = [
  {
    characterName: "龙国观众甲",
    url: "http://example.com/audio1.wav",
    fileName: "audio1.wav"
  },
  {
    characterName: "我判",
    url: "http://example.com/audio2.wav",
    fileName: "audio2.wav"
  }
]

优先级说明

生成音频时,系统按以下顺序查找参考音频:

  1. 对话组节点参考音频(最高优先级)
  1. 角色库参考音频(次优先级)
  1. 无参考音频

数据持久化

使用场景

  1. 临时角色:剧本中出现的临时角色,无需在角色库中创建
  2. 未知角色:角色库中不存在的角色
  3. 特殊音色:同一角色在不同场景需要不同音色
  4. 快速测试:测试不同音色效果,找到最合适的再创建角色

注意事项

  1. 世界选择:必须先在左上角选择世界,否则无法获取角色列表
  2. 参考音频配置
  1. 登录要求:需要用户登录后才能使用语音生成功能
  2. 生成时间:每个对话音频生成约需10-30秒,批量生成会依次执行
  3. 数据持久化:生成的音频URL和参考音频数据会保存在节点数据中,支持工作流保存/加载
  4. 角色名称匹配
  1. 音频验证

情感控制功能

情感控制方式

对话组节点支持3种情感控制方式,可以灵活控制生成音频的情感色彩:

1. 与参考音频相同 (默认)

2. 使用情感参考音频

  1. 上传音频文件:支持本地音频文件上传
  2. 连接视频节点:从视频节点自动提取音频作为情感参考

3. 使用情感向量

视频连接功能(情感参考音频模式)

功能概述

当选择“使用情感参考音频”模式时,可以将视频节点连接到对话组节点,系统会自动从视频中提取音频作为情感参考。这对于需要保持与视频场景一致的情感色彩非常有用。

使用步骤

  1. 准备节点
  1. 设置情感控制模式
  1. 创建连接
  1. 生成音频

技术实现

端口显示逻辑

// 视频输入端口始终存在,但根据模式启用/禁用
if(videoInputPort){
  if(node.data.emoControlMethod === 1){
    videoInputPort.classList.remove('disabled');
  } else {
    videoInputPort.classList.add('disabled');
  }
}

连接数据管理

// 使用 state.videoConnections 数组管理连接
state.videoConnections = [
  {
    id: 1,
    from: videoNodeId,  // 视频节点ID
    to: dialogueGroupNodeId  // 对话组节点ID
  }
];

生成音频时的优先级

// 1. 查找视频连接
const videoConn = state.videoConnections.find(c => c.to === nodeId);
if(videoConn){
  const videoNode = state.nodes.find(n => n.id === videoConn.from);
  if(videoNode && videoNode.data.url){
    form.append('emo_ref_video_url', videoNode.data.url);
  }
}
// 2. 如果没有视频连接,使用上传的音频文件
else if(node.data.emoRefAudioUrl){
  const audioBlob = await fetch(emoAudioUrl).then(r => r.blob());
  form.append('emo_ref_audio', audioBlob, 'emo_ref_audio.wav');
}

UI特性

  1. 端口样式
  1. 连接线样式
  1. 状态反馈
  1. 模式切换行为

注意事项

  1. 视频限制
  1. 连接持久化
  1. 删除连接
  1. 删除节点

音频时间轴功能

功能概述

对话组节点生成的音频现在可以添加到时间轴中,与视频片段一起进行编排和管理。音频时间轴提供了独立的音频轨道,支持拖拽排序、移除等操作。

使用方法

1. 添加音频到时间轴

生成音频后,每个对话项会显示"添加到时间轴"按钮:

【陈峰】: "龙国有救了……"
└─ [下载] [添加到时间轴]

点击按钮后:

2. 时间轴布局

时间轴现在包含两个独立轨道:

时间轴
├─ 视频轨道
│  └─ [视频片段1] [视频片段2] ...
└─ 音频轨道
   └─ [音频片段1] [音频片段2] ...

轨道特性

3. 音频片段显示

音频片段包含以下信息:

4. 音频片段操作

选中片段

移除片段

拖拽排序

技术实现

数据结构

状态管理 (state.js):

state.timeline = {
  clips: [],              // 视频片段数组
  audioClips: [],         // 音频片段数组
  nextClipId: 1,
  nextAudioClipId: 1,
  selectedClipId: null,
  selectedAudioClipId: null,
  visible: false,
}

音频片段数据:

{
  id: 1,                    // 片段唯一ID
  nodeId: 5,                // 对话组节点ID
  dialogueIndex: 0,         // 对话索引
  url: "http://...",        // 音频URL
  name: "陈峰: 龙国有救了...",  // 显示名称
  duration: 3.5,            // 音频时长(秒)
  startTime: 0,             // 剪切开始时间
  endTime: 3.5,             // 剪切结束时间
  order: 0,                 // 排序顺序
}

核心函数

添加音频到时间轴 (timeline.js):

function addAudioToTimeline(nodeId, dialogueIndex, audioUrl, audioName, duration) {
  const clip = {
    id: state.timeline.nextAudioClipId++,
    nodeId: nodeId,
    dialogueIndex: dialogueIndex,
    url: audioUrl,
    name: audioName || '音频',
    duration: duration || 5,
    startTime: 0,
    endTime: duration || 5,
    order: state.timeline.audioClips.length,
  };

  state.timeline.audioClips.push(clip);
  state.timeline.visible = true;
  renderTimeline();
}

获取音频时长 (timeline.js):

function getAudioDuration(url) {
  return new Promise((resolve, reject) => {
    const audio = document.createElement('audio');
    audio.preload = 'metadata';

    audio.addEventListener('loadedmetadata', () => {
      if (audio.duration && isFinite(audio.duration)) {
        resolve(Math.round(audio.duration * 10) / 10);
      } else {
        reject(new Error('Invalid duration'));
      }
    }, { once: true });

    audio.src = proxyDownloadUrl(url);
  });
}

渲染音频轨道 (timeline.js):

// 渲染音频片段到时间轴
const sortedAudioClips = [...state.timeline.audioClips].sort((a, b) => a.order - b.order);
let accumulatedAudioTime = 0;

audioTrack.innerHTML = sortedAudioClips.map(clip => {
  const startTime = accumulatedAudioTime;
  const actualDuration = clip.endTime - clip.startTime;
  const width = actualDuration * 10; // 10px per second
  accumulatedAudioTime += actualDuration;

  return `
    <div class="timeline-audio-clip" 
         data-audio-clip-id="${clip.id}" 
         style="left: ${startTime * 10}px; width: ${width}px;">
      <!-- 波形图标、名称、时长等 -->
    </div>
  `;
}).join('');

样式设计

音频片段样式 (video_workflow.css):

.timeline-audio-clip {
  height: 100px;
  min-width: 50px;
  background: linear-gradient(135deg, #10b981 0%, #059669 100%);
  border-radius: 8px;
  border: 2px solid transparent;
  transition: border-color 0.15s, transform 0.15s;
}

.timeline-audio-clip:hover {
  border-color: #059669;
  transform: translateY(-2px);
}

.timeline-audio-clip.selected {
  border-color: #059669;
  box-shadow: 0 0 0 2px rgba(16, 185, 129, 0.3);
}

轨道布局 (video_workflow.css):

.timeline-track-container {
  display: flex;
  align-items: stretch;
  border-bottom: 1px solid var(--border);
  min-height: 112px;
}

.timeline-track-label {
  width: 60px;
  background: #f5f5f5;
  border-right: 1px solid var(--border);
  font-size: 12px;
  font-weight: 700;
  color: var(--muted);
}

工作流保存

音频时间轴数据会随工作流一起保存:

序列化 (workflow.js):

timeline: {
  clips: state.timeline.clips.map(c => ({ ...c })),
  audioClips: state.timeline.audioClips.map(c => ({ ...c })),
  nextClipId: state.timeline.nextClipId,
  nextAudioClipId: state.timeline.nextAudioClipId,
}

恢复 (workflow.js):

if(data.timeline){
  state.timeline.clips = data.timeline.clips || [];
  state.timeline.audioClips = data.timeline.audioClips || [];
  state.timeline.nextClipId = data.timeline.nextClipId || 1;
  state.timeline.nextAudioClipId = data.timeline.nextAudioClipId || 1;
  state.timeline.visible = state.timeline.clips.length > 0 || 
                          state.timeline.audioClips.length > 0;
  renderTimeline();
}

时间轴关联机制

对话组节点需要关联到剧本分镜才能添加到时间轴。时间轴渲染组件按"分镜分区"管理时间区域,每个分区对应一个分镜,标识格式为 {scriptId}_{shotNumber}

shotNumber 字段

对话组节点通过 node.data.shotNumber 字段来标识它对应的分镜编号。这个字段在以下场景中被设置:

  1. 新建时
// nodes.js - 分镜节点创建对话组时
const dialogueGroupId = createDialogueGroupNode({
    x: dialogueGroupX,
    y: dialogueGroupY,
    dialogueData: node.data.shotJson.dialogue,
    shotNumber: node.data.shotJson.shot_number  // 传递分镜编号
});

// 确保shotNumber被正确保存
dialogueGroupNode.data.shotNumber = node.data.shotJson.shot_number;
  1. 恢复时
// dialogue_group_node.js - 从保存数据恢复
function createDialogueGroupNodeWithData(nodeData){
    createDialogueGroupNode({ 
        x: nodeData.x, 
        y: nodeData.y,
        dialogueData: nodeData.data.dialogues || [],
        shotNumber: nodeData.data.shotNumber || null  // 恢复shotNumber
    });
}

查找柱子逻辑

时间轴渲染组件通过对应函数查找对话组节点所属的分镜分区:

// timeline.js
if (node.type === 'dialogue_group' && node.data.shotNumber) {
    shotNumber = node.data.shotNumber;
    // 查找关联的剧本节点(通过分镜节点连接)
    const incomingConns = state.connections.filter(c => c.to === nodeId);
    for (const conn of incomingConns) {
        const sourceNode = state.nodes.find(n => n.id === conn.from);
        if (sourceNode && sourceNode.type === 'shot_frame') {
            const pillar = getPillarForNode(sourceNode.id);
            if (pillar) return pillar;
        }
    }
}

错误处理

如果对话组节点缺少 shotNumber 字段,添加到时间轴时会提示:

"该音频节点未关联到剧本分镜,请先解析剧本"

修复历史:在 2026年1月7日之前,对话组节点创建时没有传递 shotNumber 字段,导致无法添加到时间轴。现已修复,确保在新建和恢复两种场景下都能正确保存 shotNumber

注意事项

  1. 音频格式:支持所有浏览器支持的音频格式(WAV、MP3等)
  2. 时长获取:首次添加时自动获取音频时长,失败时使用默认值5秒
  3. URL类型:支持服务器URL和blob URL
  4. 时间轴高度:添加音频轨道后,时间轴高度从200px增加到280px
  5. 独立管理:音频和视频片段独立管理,互不影响
  6. 剧本关联:对话组节点必须从分镜节点创建,才能正确关联到时间轴

未来扩展

计划中的功能:

扩展功能

未来可以扩展的功能: