add voice clone init

This commit is contained in:
minipuding
2026-05-07 10:55:33 +08:00
parent 04297707e7
commit 741483897f
8 changed files with 1049 additions and 18 deletions
+444
View File
@@ -0,0 +1,444 @@
---
name: add-new-node
description: 在 FireRed-OpenStoryline 中添加新节点功能的完整指南。适用于用户想要扩展新的视频编辑节点(如新的 AI 处理步骤、自定义滤镜、数据转换等)时使用。触发词:添加新节点、新建节点、扩展节点、add node、new node。
---
# FireRed-OpenStoryline 添加新节点指南
## 系统架构速览
FireRed-OpenStoryline 是一个基于 MCPModel Context Protocol)的 AI 视频剪辑系统。整个编辑流程由一系列**节点(Node)**组成 DAG(有向无环图),每个节点是一个独立的处理单元,通过 MCP Tool 的形式暴露给 LLM Agent 调用。
### 核心文件位置
```
src/open_storyline/
├── nodes/
│ ├── core_nodes/ # 所有节点实现(每个节点一个文件)
│ │ ├── base_node.py # 基类 BaseNode + NodeMeta
│ │ ├── load_media.py # 示例:入口节点
│ │ ├── split_shots.py # 示例:处理节点
│ │ └── ...
│ ├── node_schema.py # 所有节点的 Input/Output Pydantic 模型
│ ├── node_manager.py # 节点注册与依赖管理器
│ ├── node_state.py # 节点执行状态(session、artifact、llm 等)
│ └── node_summary.py # 节点执行摘要(日志/进度)
├── utils/register.py # 全局 NODE_REGISTRY 注册表
└── config.py # Settings 配置 SchemaPydantic
config.toml # 主配置文件
```
---
## 节点执行机制
### Mode 模式
每个节点调用时会传入 `mode` 参数:
| mode | 调用方法 | 含义 |
|------|----------|------|
| `"auto"` | `process()` | 正常执行核心逻辑(LLM 调用、算法处理等) |
| `"skip"` | `default_process()` | 跳过该节点,返回空/透传数据 |
| `"default"` | `default_process()` | 同 skip,使用默认/简化逻辑 |
### 数据流转
上游节点的输出会通过 `inputs` 字典注入到下游节点,key 为上游节点的 `node_kind`
```python
# 在 process() 中获取上游数据
prior_data = inputs.get("split_shots", {}) # 上游 split_shots 节点的输出
clips = prior_data.get("clips", [])
```
### 节点状态对象 NodeState
```python
node_state.session_id # 当前 session ID
node_state.artifact_id # 当前执行的 artifact ID(唯一标识本次调用)
node_state.llm # LLM 客户端(可用于调用语言模型)
node_state.node_summary # 用于记录日志和进度
# 日志记录方法
node_state.node_summary.info_for_user("显示给用户看的进度信息")
node_state.node_summary.info_for_llm("给 LLM 看的执行信息")
node_state.node_summary.add_warning("警告信息", artifact_id=node_state.artifact_id)
node_state.node_summary.add_error("错误信息", artifact_id=node_state.artifact_id)
```
---
## 添加新节点的完整步骤
### Step 1:在 `node_schema.py` 定义 Input/Output Schema
打开 `src/open_storyline/nodes/node_schema.py`,参考已有模型添加:
```python
# ===== 新节点的 Input Schema =====
class MyNewNodeInput(BaseInput):
mode: Literal["auto", "skip", "default"] = Field(
default="auto",
description="auto: 执行核心逻辑; skip: 跳过; default: 使用默认行为"
)
# 添加节点特有的参数
my_param: str = Field(
default="",
description="用户对该节点的自定义参数说明"
)
# ===== 新节点的 Output Schema(可选,用于文档和类型提示) =====
class MyNewNodeOutput(BaseModel):
result: List[SomeType] = Field(
default_factory=list,
description="节点输出结果"
)
```
**注意事项:**
- `Input` 类必须继承 `BaseInput`(已包含 `mode` 字段)或 `BaseModel`
- 字段 `description` 会直接透传为 MCP Tool 的参数说明,写清楚对 LLM 很重要
- 如果节点不需要特殊输入参数,可以直接用 `class MyNewNodeInput(BaseInput): ...`(三个点代表空 body
---
### Step 2:创建节点实现文件
`src/open_storyline/nodes/core_nodes/` 下新建文件,例如 `my_new_node.py`
```python
from typing import Any, ClassVar, Dict, List, Type
from pydantic import BaseModel
from open_storyline.nodes.core_nodes.base_node import BaseNode, NodeMeta
from open_storyline.nodes.node_schema import MyNewNodeInput
from open_storyline.nodes.node_state import NodeState
from open_storyline.utils.register import NODE_REGISTRY
@NODE_REGISTRY.register() # 必须:注册到全局注册表
class MyNewNode(BaseNode):
# 必须:节点元数据
meta = NodeMeta(
name="my_new_node", # MCP Tool 名称(snake_case,唯一)
description=( # MCP Tool 描述,LLM 根据此决定何时调用
"这个节点的功能说明,写清楚它做什么、"
"什么情况下应该调用它"
),
node_id="my_new_node", # 节点唯一 ID(通常与 name 相同)
node_kind="my_new_node", # 节点类型(同类替代节点共享同一 kind)
require_prior_kind=[ # 执行 process() 时必须已完成的前置节点 kind
"filter_clips",
],
default_require_prior_kind=[ # 执行 default_process() 时的前置依赖
"filter_clips",
],
next_available_node=[ # 执行完本节点后,下游可选的节点 ID
"plan_timeline",
],
priority=5, # 同 kind 中多个节点时的优先级(越大越优先)
)
input_schema: ClassVar[Type[BaseModel]] = MyNewNodeInput
async def default_process(
self, node_state: NodeState, inputs: Dict[str, Any]
) -> Dict[str, Any]:
"""
mode != "auto" 时执行。
通常:跳过处理,透传上游数据或返回空结果。
"""
node_state.node_summary.info_for_user(
f"[{self.meta.node_id}] 跳过,使用默认结果"
)
return {"result": []}
async def process(
self, node_state: NodeState, inputs: Dict[str, Any]
) -> Dict[str, Any]:
"""
mode == "auto" 时执行。核心业务逻辑在此实现。
"""
# 1. 获取上游数据(key 为上游节点的 node_kind
prior_clips = inputs.get("filter_clips", {}).get("clip_captions", [])
node_state.node_summary.info_for_user(
f"[{self.meta.node_id}] 开始处理,共 {len(prior_clips)} 个 clip"
)
# 2. 你的核心处理逻辑
result = []
for clip in prior_clips:
# ... 处理每个 clip
result.append({...})
node_state.node_summary.info_for_user(
f"[{self.meta.node_id}] 处理完成,输出 {len(result)} 条结果"
)
return {"result": result}
```
**NodeMeta 字段说明:**
| 字段 | 类型 | 说明 |
|------|------|------|
| `name` | str | MCP Tool 的调用名,snake_case,全局唯一 |
| `description` | str | LLM 看到的工具描述,决定何时调用 |
| `node_id` | str | 节点唯一标识,通常与 name 相同 |
| `node_kind` | str | 节点功能类别,同类节点共享 kind(用于依赖解析) |
| `require_prior_kind` | List[str] | `process()` 的前置依赖 kind 列表 |
| `default_require_prior_kind` | List[str] | `default_process()` 的前置依赖 kind 列表 |
| `next_available_node` | List[str] | 执行完后下游可选节点的 node_id 列表 |
| `priority` | int | 同 kind 下多实现时的优先级,默认 5 |
---
### Step 3:在 `config.toml` 中注册节点
打开根目录的 `config.toml`,找到 `[local_mcp_server]` 部分,将新节点类名加入 `available_nodes`
```toml
[local_mcp_server]
available_node_pkgs = [
"open_storyline.nodes.core_nodes" # 扫描这个包下的所有模块
]
available_nodes = [
"LoadMediaNode",
"SplitShotsNode",
# ... 其他现有节点 ...
"MyNewNode", # 新增这行
]
```
**机制说明:**
1. 系统启动时会扫描 `available_node_pkgs` 中的所有模块,触发 `@NODE_REGISTRY.register()` 自动注册
2. 然后只实例化 `available_nodes` 中列出的类,将其包装为 MCP Tool
3. 新节点文件放在 `core_nodes/` 下会被自动扫描到,不需要手动 import
---
### Step 4(可选):添加节点独立配置
如果节点需要从 `config.toml` 读取参数(如模型路径、阈值等),在 `config.py` 中新增 Config 类:
```python
# src/open_storyline/config.py
class MyNewNodeConfig(ConfigBaseModel):
threshold: float = Field(default=0.5, description="处理阈值")
model_path: Path = Field(..., description="模型权重路径")
class Settings(ConfigBaseModel):
# ... 现有字段 ...
my_new_node: MyNewNodeConfig # 新增这行
```
然后在 `config.toml` 添加对应 section
```toml
[my_new_node]
threshold = 0.5
model_path = "./models/my_model.pth"
```
在节点实现中通过 `self.server_cfg` 访问:
```python
def __init__(self, server_cfg: Settings) -> None:
super().__init__(server_cfg)
self.threshold = self.server_cfg.my_new_node.threshold
self.model_path = self.server_cfg.my_new_node.model_path
```
---
## 完整示例:最简单的透传节点
下面是一个完整可运行的最简节点,直接透传上游数据:
```python
# src/open_storyline/nodes/core_nodes/passthrough_node.py
from typing import Any, ClassVar, Dict, Type
from pydantic import BaseModel
from open_storyline.nodes.core_nodes.base_node import BaseNode, NodeMeta
from open_storyline.nodes.node_schema import BaseInput
from open_storyline.nodes.node_state import NodeState
from open_storyline.utils.register import NODE_REGISTRY
class PassthroughNodeInput(BaseInput):
... # 只继承 mode 字段,无额外参数
@NODE_REGISTRY.register()
class PassthroughNode(BaseNode):
meta = NodeMeta(
name="passthrough",
description="透传节点:直接将上游数据传递给下游,不做任何处理",
node_id="passthrough",
node_kind="passthrough",
require_prior_kind=["filter_clips"],
default_require_prior_kind=["filter_clips"],
next_available_node=["plan_timeline"],
)
input_schema: ClassVar[Type[BaseModel]] = PassthroughNodeInput
async def default_process(self, node_state: NodeState, inputs: Dict[str, Any]) -> Dict[str, Any]:
return await self.process(node_state, inputs)
async def process(self, node_state: NodeState, inputs: Dict[str, Any]) -> Dict[str, Any]:
clips = inputs.get("filter_clips", {}).get("clip_captions", [])
node_state.node_summary.info_for_user(f"透传 {len(clips)} 个 clip")
return {"clip_captions": clips}
```
---
## 环境说明
本项目使用 **conda 虚拟环境**(不是 venv),环境名为 `storyline3`。所有 Python 命令需在该环境下运行:
```bash
# 验证节点可以正常导入
conda run -n storyline3 bash -c "PYTHONPATH=src python -c 'from open_storyline.nodes.core_nodes.my_new_node import MyNewNode; print(MyNewNode.meta.name)'"
# 验证完整注册流程(模拟服务启动时的扫描)
conda run -n storyline3 bash -c "PYTHONPATH=src python -c \"
from open_storyline.utils.register import NODE_REGISTRY
NODE_REGISTRY.scan_package('open_storyline.nodes.core_nodes')
print('MyNewNode in registry:', 'MyNewNode' in NODE_REGISTRY.list())
\""
```
---
## 节点调试技巧
### 开启 developer_mode
`config.toml` 中设置:
```toml
[developer]
developer_mode = true
```
开启后,节点异常会返回完整 traceback 而非简短错误信息。
### 查看节点执行历史
通过 MCP Tool `read_node_history` 可以读取任意 artifact_id 对应的执行结果:
```
read_node_history(query_artifact_id="xxx-artifact-id")
```
### LLM 调用示例
如果节点需要调用 LLM,通过 `node_state.llm` 使用:
```python
from open_storyline.mcp.sampling_requester import LLMClient
async def process(self, node_state: NodeState, inputs: Dict[str, Any]) -> Dict[str, Any]:
llm: LLMClient = inputs.get("llm") or node_state.llm
response = await llm.ainvoke([
{"role": "user", "content": "你的 prompt"}
])
result_text = response.content
...
```
---
## 常见错误排查
| 错误现象 | 可能原因 | 解决方案 |
|----------|----------|----------|
| 节点未出现在 MCP Tool 列表 | 类名未加入 `available_nodes` | 检查 `config.toml` |
| `KeyError: 'MyNewNode'` | 类名拼写错误或文件未被扫描到 | 检查文件在 `core_nodes/` 目录下,且类名与 config 一致 |
| `require_prior_kind` 依赖未满足 | 上游节点未执行 | 确认前置节点已执行并有输出 |
| `inputs.get("xxx")` 返回 None | node_kind 写错,或上游节点输出 key 不对 | 打印 `inputs.keys()` 检查实际 key |
| `Settings` 初始化报错 | 新增了 Config 字段但 config.toml 未对应添加 | 补充 `config.toml` 中的 section |
---
## 已有节点的节点类型(node_kind)速查
| node_kind | 节点类 | 功能 |
|-----------|--------|------|
| `load_media` | LoadMediaNode | 加载本地媒体文件 |
| `load_media` | SearchMediaNode | 从 Pexels 搜索媒体(同 kind |
| `split_shots` | SplitShotsNode | 镜头分割(TransNetV2 |
| `asr_node` | LocalASRNode | 语音识别 |
| `speech_rough_cut` | SpeechRoughCutNode | 语音粗剪 |
| `understand_clips` | UnderstandClipsNode | VLM 视觉理解 |
| `filter_clips` | FilterClipsNode | 智能筛选 |
| `group_clips` | GroupClipsNode | 镜头分组 |
| `generate_script` | GenerateScriptNode | 脚本/字幕生成 |
| `tts` | GenerateVoiceoverNode | TTS 配音生成 |
| `tts` | VoiceCloneMinimaxNode | MiniMax 音色克隆 + TTS(与上行同 kind,可互替) |
| `select_bgm` | SelectBGMNode | 背景音乐选择 |
| `plan_timeline` | PlanTimelineProNode | 时间线规划 |
| `plan_timeline_ai_transition` | PlanTimelineAITransitionNode | AI 转场时间线 |
| `generate_ai_transition` | GenerateAITransitionNode | AI 转场生成 |
| `render_video` | RenderVideoNode | 最终渲染输出 |
---
## 特殊主题:节点接收音频文件输入
### 音频文件在系统中的流转路径
```
用户上传音频(Web UI
→ 存入 session media_dir(绝对路径)
→ scan_media_dir 返回统计 + 绝对路径列表(注入到 Agent 的 system message
→ Agent 知道有哪些音频文件可用
→ Agent 在 tool call 参数中填写 clone_audio=[{"path": "/abs/path/audio.mp3"}]
→ 拦截器 inject_media_content_before 检测到 voice_clone_minimax 且 clone_audio 未传时
→ 自动从 media_dir 扫描音频文件并注入 clone_audio
→ BaseNode.load_inputs_from_client 处理 clone_audio 字段(base64/path 双模式)
→ node.process() 中 clone_audio_list[0]["path"] 是 server 本地可读路径
```
### 为什么音频文件不经过 load_media 节点?
`load_media` 节点只处理视频和图片(VIDEO_EXTS / IMAGE_EXTS),音频文件会被 skip。
音频文件不需要 `load_media` 处理,因为:
- 视频/图片需要元数据提取(duration、fps、width×height
- 音频只是一个文件路径,直接传给 TTS/音色克隆 API 即可
### 如果你的新节点也需要接收音频输入
1. **Input Schema** 中声明 `List[Dict[str, Any]]` 字段:
```python
class MyAudioNodeInput(BaseInput):
audio_files: List[Dict[str, Any]] = Field(
default_factory=list,
description="音频文件列表,格式:[{'path': '/abs/path/audio.mp3'}]"
)
```
2. **拦截器自动注入**(可选):如果希望系统自动从 media_dir 找音频注入,
`node_interceptors.py``inject_media_content_before` 中参考
`voice_clone_minimax` 的处理方式,添加 `node_id == 'my_audio_node'` 分支。
3. **节点 process() 中读取**`BaseNode.load_inputs_from_client` 自动处理 `List[dict]`
字段的 base64/path 双模式,process() 里直接 `inputs.get("audio_files")` 即可,
`item["path"]` 已经是 server 本地可 open() 的路径。
### scan_media_dir 的行为
`src/open_storyline/utils/media_handler.py` 中的 `scan_media_dir` 会统计:
- 图片数量
- 视频数量
- 音频文件的绝对路径列表(`.mp3`/`.wav`/`.m4a`
这些统计信息会注入到 Agent 的 system message,让 Agent 知道用户上传了哪些媒体。
+16
View File
@@ -309,6 +309,8 @@ def detect_media_kind(filename: str) -> str:
return "image"
if ext in {".mp4", ".mov", ".avi", ".mkv", ".webm"}:
return "video"
if ext in {".mp3", ".wav", ".m4a"}:
return "audio"
return "unknown"
@@ -3244,6 +3246,20 @@ async def ws_chat(ws: WebSocket, session_id: str):
[SystemMessage(content="\n\n".join(_system_parts))] if _system_parts else []
) + _non_system
# Strip reasoning_content from AIMessage history before sending to LLM.
# Some providers (e.g. domestic OpenAI-compatible APIs in thinking mode)
# return reasoning_content in additional_kwargs but do not accept it back,
# causing a 400 error on subsequent turns.
for _m in _merged_messages:
if (
isinstance(_m, AIMessage)
and "reasoning_content" in _m.additional_kwargs
):
_m.additional_kwargs = {
k: v for k, v in _m.additional_kwargs.items()
if k != "reasoning_content"
}
async def pump_agent():
nonlocal new_messages
try:
+2 -1
View File
@@ -47,7 +47,8 @@ available_nodes = [
"LoadMediaNode", "SearchMediaNode", "SplitShotsNode", "LocalASRNode", "SpeechRoughCutNode", "GenerateAITransitionNode",
"UnderstandClipsNode", "FilterClipsNode", "GroupClipsNode", "GenerateScriptNode", "ScriptTemplateRecomendation",
"GenerateVoiceoverNode", "SelectBGMNode", "RecommendTransitionNode", "RecommendTextNode",
"PlanTimelineProNode", "PlanTimelineAITransitionNode", "RenderVideoNode"
"PlanTimelineProNode", "PlanTimelineAITransitionNode", "RenderVideoNode",
"VoiceCloneMinimaxNode"
]
# =========== skills ==========
@@ -22,6 +22,7 @@ logger = get_logger(__name__)
# Hosts that indicate Agent and MCP server are on the same machine (path-only, no base64). 0.0.0.0 for Docker.
_LOCAL_CONNECT_HOSTS = frozenset({"127.0.0.1", "localhost", "::1", "0.0.0.0"})
_AUDIO_EXTS = {".mp3", ".wav", ".m4a"}
def should_inline_media_as_base64(server_cfg=None) -> bool:
@@ -315,6 +316,11 @@ class ToolInterceptor:
# Collect dependencies again
collect_result = meta_collector.check_excutable(session_id, store, require_kind)
load_collected_data(collect_result['collected_node'], input_data, store)
if node_id == 'voice_clone_minimax' and not request.args.get('clone_audio'):
audio_files = [str(p.resolve()) for p in Path(context.media_dir).iterdir() if p.is_file() and p.suffix.lower() in _AUDIO_EXTS]
if audio_files:
input_data['clone_audio'] = audio_files[0]
logger.info(f'[voice_clone_minimax] Auto-injected clone_audio: {audio_files[0]}')
else:
input_data['artifacts_dir'] = store.artifacts_dir
@@ -441,10 +447,13 @@ class ToolInterceptor:
Interceptor: Injects runtime.context.tts_config parameters into request.args before invoking voiceover/TTS tools.
- tts_config: {"provider": "bytedance", "bytedance": {...}, "azure": {...}, ...}
"""
tool_name = str(getattr(request, 'name', '') or '')
if not any(kw in tool_name for kw in ('voiceover', 'voice_clone_minimax')):
return await handler(request)
return await ToolInterceptor._inject_provider_config(
request,
handler,
tool_name_keyword="voiceover",
tool_name_keyword="", # already filtered above
context_attr="tts_config",
default_provider="minimax",
)
@@ -0,0 +1,481 @@
"""
MiniMax Voice Clone + Voiceover Node
=====================================
Replaces generate_voiceover when the user wants to narrate with a cloned voice.
Pipeline position
-----------------
group_clips ──┐
├──► voice_clone_minimax ──► plan_timeline ──► render_video
generate_script ─┘
This node shares node_kind="tts" with GenerateVoiceoverNode so plan_timeline
(which require_prior_kind=["tts"]) can treat them interchangeably.
What it does
------------
1. Clone voice upload clone_audio (and optional prompt_audio) to MiniMax,
get back a persistent voice_id.
2. Generate TTS for every group_script, call MiniMax T2A v2 with the cloned
voice_id, save each wav, return the standard voiceover list.
Output format (identical to GenerateVoiceoverNode)
------------------------------------------------------
{
"voiceover": [
{"voiceover_id": "voiceover_0001", "group_id": "group_0001",
"path": "/…/voiceover_0001_<ts>.wav", "duration": 3500},
]
}
File input format (clone_audio / prompt_audio)
-----------------------------------------------
List-of-dicts, same convention as load_media.inputs, so both local-path and
remote base64 transport are handled transparently by BaseNode.load_inputs_from_client:
Local: [{"path": "/abs/path/audio.mp3"}]
Remote: [{"path": "audio.mp3", "base64": "<b64>", "md5": "<md5>"}]
After load_inputs_from_client, item["path"] is already a server-local path.
API reference: https://platform.minimaxi.com/docs/guides/speech-voice-clone
"""
from __future__ import annotations
import asyncio
import binascii
import time
import uuid
from pathlib import Path
from typing import Any, ClassVar, Dict, List, Optional, Type
import requests
from pydantic import BaseModel
from open_storyline.nodes.core_nodes.base_node import BaseNode, NodeMeta
from open_storyline.nodes.node_schema import VoiceCloneMinimaxInput
from open_storyline.nodes.node_state import NodeState
from open_storyline.utils.logging import get_logger
from open_storyline.utils.register import NODE_REGISTRY
logger = get_logger(__name__)
_DEFAULT_BASE_URL = "https://api.minimaxi.com"
_MILLISECONDS_PER_SECOND = 1000.0
@NODE_REGISTRY.register()
class VoiceCloneMinimaxNode(BaseNode):
"""
Clone a voice then generate voiceover for all script groups with that voice.
node_kind="tts" makes this node a drop-in replacement for GenerateVoiceoverNode
from the perspective of downstream nodes (plan_timeline, render_video).
"""
meta = NodeMeta(
name="voice_clone_minimax",
description=(
"Clone a voice using MiniMax's Voice Clone API, then generate voiceover "
"for every script group using the cloned voice. "
"Use this instead of generate_voiceover when the user wants narration in "
"their own voice or a specific person's voice. "
"Requires clone_audio (mp3/m4a/wav, 10s5min, ≤20MB). "
"Optionally accepts prompt_audio (<8s) to improve clone quality. "
"Output format is identical to generate_voiceover and feeds directly into plan_timeline."
),
node_id="voice_clone_minimax",
node_kind="tts", # same kind as GenerateVoiceoverNode
require_prior_kind=["group_clips", "generate_script"],
default_require_prior_kind=["group_clips", "generate_script"],
next_available_node=["plan_timeline", "select_bgm"],
priority=5,
)
input_schema: ClassVar[Type[BaseModel]] = VoiceCloneMinimaxInput
# ------------------------------------------------------------------
# Public entry points
# ------------------------------------------------------------------
async def default_process(
self, node_state: NodeState, inputs: Dict[str, Any]
) -> Dict[str, Any]:
"""Skip mode: return empty voiceover list (same shape as GenerateVoiceoverNode)."""
node_state.node_summary.info_for_user(
"[voice_clone_minimax] Skipped — no voiceover generated."
)
return {"voiceover": []}
async def process(
self, node_state: NodeState, inputs: Dict[str, Any]
) -> Dict[str, Any]:
"""Auto mode: clone voice, then generate TTS for every group script."""
# ---- 1. Resolve credentials ---------------------------------------
api_key, base_url = self._resolve_credentials(inputs, node_state)
# ---- 2. Get group scripts from upstream generate_script -----------
group_scripts = (inputs.get("generate_script") or {}).get("group_scripts") or []
if not isinstance(group_scripts, list) or not group_scripts:
node_state.node_summary.info_for_user(
"[voice_clone_minimax] No group_scripts found — skipping voiceover generation."
)
return {"voiceover": []}
# ---- 3. Extract clone_audio (already decoded by load_inputs_from_client) --
clone_audio_list: List[Dict[str, Any]] = inputs.get("clone_audio") or []
if not clone_audio_list:
raise ValueError(
"clone_audio is required. "
'Pass [{"path": "/abs/path/audio.mp3"}] in local mode, or '
'[{"path": "filename.mp3", "base64": "...", "md5": "..."}] in remote/web mode.'
)
clone_audio_file = Path(clone_audio_list[0]["path"])
if not clone_audio_file.exists():
raise FileNotFoundError(
f"Clone audio file not found on server: {clone_audio_file}"
)
# ---- 4. Extract optional prompt_audio -----------------------------
prompt_audio_list: List[Dict[str, Any]] = inputs.get("prompt_audio") or []
prompt_audio_file: Optional[Path] = None
if prompt_audio_list:
p = Path(prompt_audio_list[0]["path"])
if p.exists():
prompt_audio_file = p
else:
node_state.node_summary.add_warning(
f"[voice_clone_minimax] prompt_audio not found, skipping: {p}",
artifact_id=node_state.artifact_id,
)
# ---- 5. Determine voice_id ----------------------------------------
custom_voice_id = (inputs.get("voice_id") or "").strip()
if not custom_voice_id:
custom_voice_id = f"cloned_{int(time.time())}_{uuid.uuid4().hex[:6]}"
node_state.node_summary.info_for_user(
f"[voice_clone_minimax] No voice_id provided — auto-generated: {custom_voice_id}"
)
# ---- 6. Prepare output directory ----------------------------------
output_dir = self._prepare_output_directory(node_state)
# ---- 7. Clone voice (blocking HTTP, run in thread) ----------------
cloned_voice_id = await asyncio.to_thread(
self._clone_voice_sync,
api_key=api_key,
base_url=base_url,
clone_audio_file=clone_audio_file,
voice_id=custom_voice_id,
prompt_audio_file=prompt_audio_file,
prompt_text=(inputs.get("prompt_text") or "").strip(),
node_state=node_state,
)
# ---- 8. Generate TTS for every group script -----------------------
model = (inputs.get("model") or "speech-02-hd").strip()
speed = float(inputs.get("speed") or 1.0)
ts_ms = int(time.time() * 1000)
voiceover: List[Dict[str, Any]] = []
for i, group in enumerate(group_scripts, start=1):
group_id = (group or {}).get("group_id", "")
raw_text = (group or {}).get("raw_text", "")
if not group_id:
raise ValueError(f"Missing group_id in group_scripts[{i}]: {group}")
if not isinstance(raw_text, str) or not raw_text.strip():
raise ValueError(
f"raw_text is empty for group_id={group_id}, cannot generate speech."
)
voiceover_id = f"voiceover_{i:04d}"
wav_path = output_dir / f"{voiceover_id}_{ts_ms}.wav"
await asyncio.to_thread(
self._tts_minimax_sync,
api_key=api_key,
base_url=base_url,
text=raw_text,
voice_id=cloned_voice_id,
model=model,
speed=speed,
wav_path=wav_path,
)
duration = self._audio_duration_ms(wav_path)
voiceover.append(
{
"voiceover_id": voiceover_id,
"group_id": group_id,
"path": str(wav_path),
"duration": duration,
}
)
node_state.node_summary.info_for_user(
f"[voice_clone_minimax] Generated {voiceover_id} ({duration}ms)",
preview_urls=[str(wav_path)],
)
node_state.node_summary.info_for_user(
f"[voice_clone_minimax] Done. voice_id={cloned_voice_id}, "
f"{len(voiceover)} voiceover segment(s) generated."
)
return {"voiceover": voiceover}
# ------------------------------------------------------------------
# Step 1: Voice cloning
# ------------------------------------------------------------------
def _clone_voice_sync(
self,
*,
api_key: str,
base_url: str,
clone_audio_file: Path,
voice_id: str,
prompt_audio_file: Optional[Path],
prompt_text: str,
node_state: NodeState,
) -> str:
"""Upload audio, call /v1/voice_clone, return the confirmed voice_id."""
headers_auth = {"Authorization": f"Bearer {api_key}"}
upload_url = base_url.rstrip("/") + "/v1/files/upload"
# Upload clone audio
node_state.node_summary.info_for_user(
f"[voice_clone_minimax] Uploading clone audio: {clone_audio_file.name}"
)
file_id = self._upload_file(
upload_url=upload_url,
headers=headers_auth,
file_path=clone_audio_file,
purpose="voice_clone",
)
node_state.node_summary.info_for_user(
f"[voice_clone_minimax] Clone audio uploaded, file_id={file_id}"
)
# (Optional) Upload prompt audio
prompt_file_id: Optional[str] = None
if prompt_audio_file is not None:
node_state.node_summary.info_for_user(
f"[voice_clone_minimax] Uploading prompt audio: {prompt_audio_file.name}"
)
prompt_file_id = self._upload_file(
upload_url=upload_url,
headers=headers_auth,
file_path=prompt_audio_file,
purpose="prompt_audio",
)
node_state.node_summary.info_for_user(
f"[voice_clone_minimax] Prompt audio uploaded, file_id={prompt_file_id}"
)
# Call voice_clone API
clone_url = base_url.rstrip("/") + "/v1/voice_clone"
clone_payload: Dict[str, Any] = {
"file_id": file_id,
"voice_id": voice_id,
}
if prompt_file_id:
clone_payload["clone_prompt"] = {
"prompt_audio": prompt_file_id,
"prompt_text": prompt_text,
}
resp = requests.post(
clone_url,
headers={**headers_auth, "Content-Type": "application/json"},
json=clone_payload,
timeout=120,
)
resp.raise_for_status()
resp_json = resp.json()
base_resp = (resp_json or {}).get("base_resp") or {}
status_code = base_resp.get("status_code")
if status_code not in (0, None):
raise RuntimeError(
f"MiniMax voice_clone API error: status_code={status_code}, "
f"status_msg={base_resp.get('status_msg')}, response={resp_json}"
)
node_state.node_summary.info_for_user(
f"[voice_clone_minimax] Voice cloned successfully. voice_id={voice_id}"
)
return voice_id
# ------------------------------------------------------------------
# Step 2: TTS with cloned voice (MiniMax T2A v2)
# ------------------------------------------------------------------
def _tts_minimax_sync(
self,
*,
api_key: str,
base_url: str,
text: str,
voice_id: str,
model: str,
speed: float,
wav_path: Path,
) -> None:
"""Call MiniMax T2A v2 with the cloned voice_id and save wav to disk."""
api_url = base_url.rstrip("/") + "/v1/t2a_v2"
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
}
body = {
"model": model,
"text": text,
"stream": False,
"output_format": "hex",
"voice_setting": {
"voice_id": voice_id,
"speed": max(0.5, min(2.0, speed)),
"vol": 1.0,
"pitch": 0,
},
"audio_setting": {
"sample_rate": 24000,
"bitrate": 128000,
"format": "wav",
},
}
resp = requests.post(api_url, headers=headers, json=body, timeout=120)
resp.raise_for_status()
resp_json = resp.json()
base_resp = (resp_json or {}).get("base_resp") or {}
if base_resp.get("status_code") not in (0, None):
raise RuntimeError(f"MiniMax TTS failed: {resp_json}")
data = (resp_json or {}).get("data") or {}
audio_field = data.get("audio")
if not audio_field:
raise RuntimeError(f"MiniMax TTS returned no audio data: {resp_json}")
if isinstance(audio_field, str) and audio_field.startswith("http"):
audio_resp = requests.get(audio_field, timeout=120)
audio_resp.raise_for_status()
wav_path.write_bytes(audio_resp.content)
else:
try:
wav_path.write_bytes(binascii.unhexlify(audio_field))
except Exception as e:
raise RuntimeError(
f"MiniMax TTS hex decode failed: {e}, "
f"audio_field[:64]={str(audio_field)[:64]}"
)
# ------------------------------------------------------------------
# Helpers
# ------------------------------------------------------------------
def _resolve_credentials(
self, inputs: Dict[str, Any], node_state: NodeState
) -> tuple[str, str]:
"""
Resolve MiniMax API key. Priority:
1. inputs["api_key"]
2. config.toml [generate_voiceover.providers.minimax].api_key
3. env MINIMAX_API_KEY / TTS_MINIMAX_API_KEY
"""
import os
api_key = (inputs.get("api_key") or "").strip()
if not api_key:
try:
providers = (
getattr(self.server_cfg.generate_voiceover, "providers", {}) or {}
)
api_key = (
(providers.get("minimax") or {}).get("api_key") or ""
).strip()
except Exception:
pass
if not api_key:
for env_var in ("MINIMAX_API_KEY", "TTS_MINIMAX_API_KEY"):
api_key = (os.getenv(env_var) or "").strip()
if api_key:
break
if not api_key:
node_state.node_summary.info_for_llm(
"MiniMax API key is missing. Ask the user to provide it via the sidebar "
"or config.toml [generate_voiceover.providers.minimax] api_key."
)
raise ValueError(
"MiniMax API key not found. Set it in config.toml under "
"[generate_voiceover.providers.minimax] api_key, or via the "
"MINIMAX_API_KEY environment variable."
)
raw_url = (inputs.get("base_url") or "").strip()
if raw_url:
# Keep only scheme+host; strip any path that may come from TTS endpoint config
from urllib.parse import urlparse
_p = urlparse(raw_url)
base_url = f"{_p.scheme}://{_p.netloc}" if _p.netloc else raw_url
else:
base_url = _DEFAULT_BASE_URL
return api_key, base_url
def _upload_file(
self,
*,
upload_url: str,
headers: Dict[str, str],
file_path: Path,
purpose: str,
) -> Any:
"""Upload a file to MiniMax /v1/files/upload and return file_id."""
with open(file_path, "rb") as f:
resp = requests.post(
upload_url,
headers=headers,
data={"purpose": purpose},
files={"file": (file_path.name, f)},
timeout=120,
)
resp.raise_for_status()
resp_json = resp.json()
base_resp = (resp_json or {}).get("base_resp") or {}
if base_resp.get("status_code") not in (0, None):
raise RuntimeError(
f"MiniMax file upload failed: status_code={base_resp.get('status_code')}, "
f"status_msg={base_resp.get('status_msg')}"
)
file_id = (resp_json.get("file") or {}).get("file_id")
if not file_id:
raise RuntimeError(
f"MiniMax file upload returned no file_id. Response: {resp_json}"
)
return file_id
def _audio_duration_ms(self, audio_path: Path) -> int:
"""Return audio duration in milliseconds."""
try:
import librosa
return int(
round(
librosa.get_duration(path=str(audio_path))
* _MILLISECONDS_PER_SECOND
)
)
except Exception as e:
logger.warning(f"Could not read duration of {audio_path}: {e}")
return 0
+56
View File
@@ -178,6 +178,60 @@ class BaseInput(BaseModel):
)
class VoiceCloneMinimaxInput(BaseInput):
mode: Literal["auto", "skip", "default"] = Field(
default="auto",
description=(
"auto: Clone voice then generate voiceover for all script groups using the cloned voice; "
"skip/default: Skip cloning and return empty voiceover list."
),
)
clone_audio: List[Dict[str, Any]] = Field(
default_factory=list,
description=(
"Audio file to clone. Pass exactly one item. "
'Local mode: [{"path": "/abs/path/audio.mp3"}]. '
'Remote/web mode: [{"path": "filename.mp3", "base64": "<b64>", "md5": "<md5>"}]. '
"Supported formats: mp3, m4a, wav. Duration: 10s-5min. Max size: 20MB."
),
)
voice_id: str = Field(
default="",
description=(
"Custom voice_id to assign to the cloned voice (e.g. 'my_cloned_voice_001'). "
"Must be unique within your MiniMax account. "
"If empty, a unique id will be auto-generated."
),
)
prompt_audio: List[Dict[str, Any]] = Field(
default_factory=list,
description=(
"Optional reference audio to improve clone quality. Pass at most one item, same format as clone_audio. "
"Duration < 8s. Max size: 20MB. Leave empty to skip."
),
)
prompt_text: str = Field(
default="",
description=(
"Optional: transcript text matching prompt_audio, used together with prompt_audio "
"to guide the cloning. Leave empty if no prompt audio is provided."
),
)
model: str = Field(
default="speech-02-hd",
description="MiniMax TTS model for voiceover generation. Options: speech-02-hd, speech-02-turbo, speech-2.6-hd.",
)
speed: float = Field(
default=1.0,
description="Speech speed multiplier, range 0.5-2.0.",
)
user_request: str = Field(
default="",
description="User's additional requirements for the voiceover style or delivery.",
)
class LoadMediaInput(BaseInput):
...
@@ -319,6 +373,8 @@ class RecommendScriptTemplateInput(BaseInput):
class GenerateVoiceoverOutput(BaseModel):
voiceover: List[Voiceover] = Field(default_factory=list, description="Voiceover list")
VoiceCloneMinimaxOutput = GenerateVoiceoverOutput
class SelectBGMInput(BaseInput):
mode: Literal["auto", "skip", "default"] = Field(
+13 -3
View File
@@ -1,14 +1,17 @@
import os
from pathlib import Path
from typing import Union
from typing import List, Union
_MEDIA_EXTS_IMG = {".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp"}
_MEDIA_EXTS_VID = {".mp4", ".mov", ".avi", ".mkv", ".webm", ".m4v"}
_MEDIA_EXTS_AUD = {".mp3", ".wav", ".m4a"}
def scan_media_dir(media_dir: Union[Path, str]) -> dict:
image_num, video_num = 0, 0
audio_paths: List[str] = []
media_dir = Path(media_dir)
media_dir.mkdir(parents=True, exist_ok=True)
media_dir.mkdir(parents=True, exist_ok=True)
for path in media_dir.iterdir():
name = path.name
@@ -23,8 +26,15 @@ def scan_media_dir(media_dir: Union[Path, str]) -> dict:
image_num += 1
elif ext in _MEDIA_EXTS_VID:
video_num += 1
elif ext in _MEDIA_EXTS_AUD:
audio_paths.append(str(path.resolve()))
return {
result: dict = {
"image number in user's media library": image_num,
"video number in user's media library": video_num,
}
if audio_paths:
result[
"audio files in user's media library (use absolute path as clone_audio when voice cloning)"
] = audio_paths
return result
+27 -13
View File
@@ -115,9 +115,9 @@ const __OS_I18N = {
"toast.switch_while_streaming": "正在生成回复,暂时无法切换会话。请先等待完成或打断当前回复。",
"toast.session_restore_unavailable": "暂时无法从服务器恢复会话(网络或服务繁忙)。请稍后刷新或重试;本地会话 ID 已保留。",
"toast.uploading_interrupt_send": "素材正在上传中,暂时无法发送新消息。已为你打断当前回复;上传完成后再按 Enter 发送。",
"toast.media_all_filtered": "仅支持上传视频图片文件。",
"toast.media_partial_filtered": "已过滤 {n} 个不支持的文件类型,仅上传视频/图片。",
"toast.audio_not_supported": "暂不支持音频文件上传(后端尚未支持音频处理)。",
"toast.media_all_filtered": "仅支持上传视频图片或音频文件。",
"toast.media_partial_filtered": "已过滤 {n} 个不支持的文件类型,仅上传视频/图片/音频。",
"toast.audio_not_supported": "暂不支持音频格式,请上传 mp3、wav 或 m4a 文件。",
// tools
"tool.card.default_name": "工具调用",
@@ -239,9 +239,9 @@ const __OS_I18N = {
"toast.switch_while_streaming": "A reply is still being generated. Please wait or interrupt before switching chats.",
"toast.session_restore_unavailable": "Could not restore the session from the server (network or temporary overload). Please retry later or refresh. Your local session id is kept.",
"toast.uploading_interrupt_send": "Media is uploading, so a new message can't be sent yet. I interrupted the current reply; press Enter after the upload finishes.",
"toast.media_all_filtered": "Only video or image files are supported.",
"toast.media_partial_filtered": "{n} unsupported file(s) were filtered; only video/image files will be uploaded.",
"toast.audio_not_supported": "Audio uploads are not supported yet (backend audio processing is not available).",
"toast.media_all_filtered": "Only video, image, or audio files are supported.",
"toast.media_partial_filtered": "{n} unsupported file(s) were filtered; only video/image/audio files will be uploaded.",
"toast.audio_not_supported": "This audio format is not supported. Please upload mp3, wav, or m4a files.",
// tools
"tool.card.default_name": "Tool call",
@@ -3873,35 +3873,49 @@ class App {
let files = Array.isArray(rawFiles) ? rawFiles.slice() : Array.from(rawFiles || []);
if (!files.length) return;
const isAudioFile = (f) => {
// 支持的音频后缀(用于 voice_clone_minimax 等节点)
const isSupportedAudioFile = (f) => {
if (!f) return false;
const type = String(f.type || "").toLowerCase();
if (type.startsWith("audio/")) return true;
if (type.startsWith("audio/")) {
// 仅放行常见格式,屏蔽小众格式(aac/flac/ogg/opus 后端暂不处理)
return /audio\/(mpeg|mp3|wav|x-wav|m4a|x-m4a|mp4)/.test(type);
}
const name = String(f.name || "").toLowerCase();
return /\.(mp3|wav|m4a|aac|flac|ogg|opus)$/.test(name);
return /\.(mp3|wav|m4a)$/.test(name);
};
// 仅允许视频/图片(音频暂不支持:后端没有处理逻辑
// 不支持的音频格式(给出专门提示
const isUnsupportedAudioFile = (f) => {
if (!f) return false;
const type = String(f.type || "").toLowerCase();
if (type.startsWith("audio/") && !isSupportedAudioFile(f)) return true;
const name = String(f.name || "").toLowerCase();
return /\.(aac|flac|ogg|opus)$/.test(name);
};
// 仅允许视频/图片/支持的音频格式
const isSupportedMediaFile = (f) => {
if (!f) return false;
const type = String(f.type || "").toLowerCase();
if (type.startsWith("video/") || type.startsWith("image/")) {
return true;
}
if (isSupportedAudioFile(f)) return true;
// 对部分没有正确 MIME 的文件,fallback 到后缀判断
const name = String(f.name || "").toLowerCase();
return /\.(mp4|mov|m4v|avi|mkv|webm|flv|wmv|jpg|jpeg|png|gif|webp|bmp|tiff)$/.test(name);
};
const beforeCount = files.length;
const audioCount = files.filter(isAudioFile).length;
files = files.filter((f) => isSupportedMediaFile(f) && !isAudioFile(f));
const unsupportedAudioCount = files.filter(isUnsupportedAudioFile).length;
files = files.filter(isSupportedMediaFile);
const filteredCount = beforeCount - files.length;
if (!files.length) {
// 全部被过滤,直接提示并返回
try {
if (audioCount > 0) {
if (unsupportedAudioCount > 0) {
this.ui.showToastI18n("toast.audio_not_supported", {});
} else {
this.ui.showToastI18n("toast.media_all_filtered", {});