Compare commits

...

10 Commits
v0.8.6 ... main

Author SHA1 Message Date
viccy
a9e17d0e36
Merge pull request #275 from cindyxu1030/feat/sonilo-sfx
feat(sfx): 新增 Sonilo AI 音效模式(可选,默认关闭)
2026-07-23 11:57:43 +08:00
Cindy Xu
14828256de feat(sfx): 新增 Sonilo AI 音效模式(可选,默认关闭)
#270 配乐模式基础上新增可选的 "AI 音效(Sonilo)":将合并后的成片
上传到 Sonilo API(POST /v1/video-to-sfx,异步任务管线),根据画面内容
生成音效,再用 ffmpeg 混在成片现有音轨之下(视频流直接复制,不重编码
画面)。解说配音在后续合成步骤中单独混入,音量策略不受影响。生成的
音效为免版税素材。

- 复用 #270 的 sonilo_api_key 配置与 app/services/sonilo.py 模块,
  新增音效任务管线(提交 / 轮询 / 预签名 URL 下载)
- 上传前 ffprobe 本地校验时长(音效接口上限 3 分钟),避免白传计费
- 轮询是免费幂等 GET:网络抖动与 5xx 在截止时间内重试,不报废已计费任务
- 任何失败(超时、HTTP 错误、任务失败、混音失败)只记录日志并沿用
  原视频,绝不中断成片任务
- WebUI 音频设置新增独立开关面板(默认关闭),选项处提示视频将上传至
  Sonilo API
- 现有 BGM / 合成逻辑不做任何改动,未勾选时行为与 main 完全一致
2026-07-22 13:17:14 -07:00
linyq-laien
d46d2ca2a5 docs: restore original README content 2026-07-22 11:57:46 +08:00
linyq-laien
5ac4488fa3 docs: optimize AI narration search visibility 2026-07-22 11:23:59 +08:00
linyq-laien
7c2bfbe233 docs: link NarratoAI project distinction guide 2026-07-21 20:32:33 +08:00
linyq-laien
0a5dcf5f21 功能: 新增字幕任务专用高效模型支持
新增高推理模型和高效模型的中英文国际化配置字符串
新增高效文本模型配置项,支持空值时自动回退到高推理模型
添加resolve_text_model_name工具函数用于根据偏好解析对应文本模型
更新字幕校对和翻译服务,默认使用高效模型处理批量字幕任务
优化OpenAI兼容提供商的模型参数处理逻辑,支持通过参数覆盖模型名称
更新WebUI基础设置页面,新增高效模型配置项并支持测试两个模型的连接
同步更新示例配置文件、默认配置项与相关测试用例
2026-07-19 14:53:06 +08:00
linyq-laien
022b8bbea3 feat(webui, llm, i18n): 添加可自定义的解说文案字数配置
在WebUI脚本设置页面新增字数输入控件,支持100-5000区间的目标字数设置
更新LLM提示词模板,使用用户指定字数作为目标(±10%浮动)替代原固定范围
补充中英文国际化翻译文案
升级项目版本至0.8.7
更新相关服务函数与单元测试适配新参数
2026-07-18 04:26:44 +08:00
linyq-laien
7639c9d289 feat(audio): 新增voxcpm-0.5b和voxcpm-2b本地语音合成支持
新增完整的voxcpm系列本地语音合成功能,包括:
- 新增配置常量、加载逻辑与示例配置项
- 集成webui界面:引擎选型、参数配置与语音预览
- 实现核心调用逻辑,包含API地址标准化、重试机制与文件处理
- 新增两套单元测试覆盖核心流程
- 在字幕转录设置页面补充转录工具包的下载提示文本
2026-07-16 19:11:51 +08:00
viccy
15af56cbd3 docs: 更新文档 2026-07-16 11:35:31 +08:00
viccy
0c9579c56a docs: update README for v0.8.5 and v0.8.6 2026-07-16 11:33:08 +08:00
32 changed files with 1677 additions and 56 deletions

1
.gitignore vendored
View File

@ -55,6 +55,7 @@ tests/*
!tests/test_generate_script_docu_unittest.py
!tests/test_streamlit_widget_session_state.py
!tests/test_sonilo_bgm_unittest.py
!tests/test_sonilo_sfx_unittest.py
docs/reddit-community
docs/wechat-0.8

View File

@ -3,7 +3,7 @@
<h3 align="center">一站式 AI 影视解说+自动化剪辑工具🎬🎞️ </h3>
<p align="center">
📖 <a href="README-en.md">English</a> | 简体中文 | <a href="https://www.narratoai.co">☁️ <b>云端版入口 (NarratoAI.cn)</b></a>
📖 <a href="README-en.md">English</a> | 简体中文 | <a href="https://www.narratoai.co">☁️ <b>云端版入口 (NarratoAI.co)</b></a>
</p>
<br>
@ -29,6 +29,8 @@ NarratoAI 是一款自动化影视解说工具,基于 LLM 实现文案撰写
</div>
## 最新资讯
- 2026.07.16 发布新版本 0.8.6,新增可选的 **Sonilo AI 配乐**,支持 Apple Silicon 的 **IndexTTS-1.5 MLX**,更新 IndexTTS-2 MLX API 适配,并补充 OmniVoice 部署包下载入口
- 2026.07.13 发布新版本 0.8.5,完善 `uv` + Python 3.12 本地运行流程,新增一键清理系统缓存和自定义 OpenAI 兼容 Base URL 的格式校验与 API Key 风险提示,并优化短脚本解析、字幕预览和音频设置的跨平台兼容性
- 2026.07.02 发布新版本 0.8.4,升级豆包语音 TTS 新版 API Key 配置并保留旧版凭据兼容
- 2026.06.10 发布新版本 0.8.1**大版本更新**,优化多个核心流程
- 2026.04.27 发布新版本 0.7.9,新增 **Fun-ASR一键转录字幕**
@ -46,6 +48,17 @@ NarratoAI 是一款自动化影视解说工具,基于 LLM 实现文案撰写
- 2024.11.10 发布官方文档,详情参见 [官方文档](https://p9mf6rjv3c.feishu.cn/wiki/SP8swLLZki5WRWkhuFvc2CyInDg)
- 2024.11.10 发布新版本 v0.3.5;优化视频剪辑流程,
### v0.8.6 更新亮点
| 功能 | 说明 |
| --- | --- |
| Sonilo AI 配乐 | 在 WebUI 的背景音乐来源中选择“AI 生成配乐Sonilo即可根据画面内容和剪辑节奏生成配乐。该功能默认关闭需自行配置 [Sonilo](https://sonilo.com) API Key当前支持最长 6 分钟的视频,生成失败时会自动回退到随机背景音乐,不中断成片任务。 |
| IndexTTS-1.5 macOS | 新增面向 Apple Silicon 的 MLX 本地语音克隆引擎,可上传或从资源目录选择参考音频。[下载部署包](https://cutagent.online/resources/indextts15-mlx-macos) |
| IndexTTS-2 MLX | 更新 MLX Pack API 适配,支持参考音频、情感控制、随机种子和更完整的生成参数。[macOS 部署包](https://cutagent.online/resources/indextts2-full-macos)Windows 版本待更新) |
| OmniVoice | 补充 OmniVoice-Pack 部署包下载入口;该引擎支持自动音色、指令音色和参考音频克隆。[macOS 部署包](https://cutagent.online/resources/omnivoice-macos) / [Windows 部署包](https://cutagent.online/resources/omnivoice-windows) |
> 使用 Sonilo AI 配乐时,合成完成且尚未添加背景音乐的视频会上传至 Sonilo API。请在启用前确认视频内容符合相关服务条款与隐私要求生成音乐的授权和商用范围以 Sonilo 最新条款为准。
## 重磅福利 🎉
> 即日起全面支持硅基流动注册即享2000万免费Token价值16元平台配额剪辑10分钟视频仅需0.1元!
@ -64,6 +77,8 @@ NarratoAI 是一款自动化影视解说工具,基于 LLM 实现文案撰写
## ⚠️谨防被骗 📢
> 🔎 名称辨析:[**NarratoAI 与 NarratorAI 项目关系、开源范围与使用方式说明**](https://github.com/linyqh/NarratoAI/wiki/NarratoAI-%E4%B8%8E-NarratorAI-%E9%A1%B9%E7%9B%AE%E5%85%B3%E7%B3%BB%E5%92%8C%E5%8C%BA%E5%88%AB)
_**1. NarratoAI 是一款完全免费的软件,近期在社交媒体(抖音,B站等)上发现,有人将 NarratoAI 改名后售卖,下面是部分截图,请大家务必提高警惕,切勿上当受骗**_
---
@ -92,6 +107,7 @@ _**1. NarratoAI 是一款完全免费的软件,近期在社交媒体(抖音,B
- [ ] 主角人脸匹配
- [ ] 支持根据口播,文案,视频素材自动匹配
- [X] 支持更多 TTS 引擎
- [X] 支持可选的 AI 自动配乐
- [ ] ...
## 快速启动 🚀

View File

@ -17,10 +17,16 @@ INDEXTTS2_ENGINE = "indextts2"
INDEXTTS2_DISPLAY_NAME = "IndexTTS-2"
OMNIVOICE_ENGINE = "omnivoice"
OMNIVOICE_DISPLAY_NAME = "OmniVoice"
VOXCPM_ENGINE = "voxcpm_05b"
VOXCPM_DISPLAY_NAME = "VoxCPM-0.5B"
VOXCPM2_ENGINE = "voxcpm_2b"
VOXCPM2_DISPLAY_NAME = "VoxCPM-2B"
INDEXTTS_VOICE_PREFIX = f"{INDEXTTS_ENGINE}:"
INDEXTTS_MACOS_VOICE_PREFIX = f"{INDEXTTS_MACOS_ENGINE}:"
INDEXTTS2_VOICE_PREFIX = f"{INDEXTTS2_ENGINE}:"
OMNIVOICE_VOICE_PREFIX = f"{OMNIVOICE_ENGINE}:"
VOXCPM_VOICE_PREFIX = f"{VOXCPM_ENGINE}:"
VOXCPM2_VOICE_PREFIX = f"{VOXCPM2_ENGINE}:"
INDEXTTS2_EMOTION_VECTOR_FIELDS = (
("happy", "vec_happy"),
("angry", "vec_angry"),
@ -175,6 +181,8 @@ def save_config():
_cfg["indextts_macos"] = indextts_macos
_cfg["indextts2"] = indextts2
_cfg["omnivoice"] = omnivoice
_cfg["voxcpm_05b"] = voxcpm_05b
_cfg["voxcpm_2b"] = voxcpm_2b
_cfg["doubaotts"] = doubaotts
f.write(toml.dumps(_cfg))
@ -194,6 +202,8 @@ indextts = _cfg.get("indextts", {})
indextts_macos = _cfg.get("indextts_macos", {})
indextts2 = _cfg.get("indextts2", {})
omnivoice = _cfg.get("omnivoice", {})
voxcpm_05b = _cfg.get("voxcpm_05b", {})
voxcpm_2b = _cfg.get("voxcpm_2b", {})
doubaotts = _cfg.get("doubaotts", {})
hostname = socket.gethostname()

View File

@ -10,6 +10,7 @@ DEFAULT_VISION_OPENAI_MODEL_NAME = "Qwen/Qwen3.5-122B-A10B"
DEFAULT_TEXT_LLM_PROVIDER = DEFAULT_OPENAI_COMPATIBLE_PROVIDER
DEFAULT_TEXT_OPENAI_MODEL_NAME = "Pro/zai-org/GLM-5"
DEFAULT_TEXT_OPENAI_FAST_MODEL_NAME = ""
DEFAULT_LLM_GENERATION_CONFIG = {
"temperature": 1.0,
@ -33,6 +34,7 @@ DEFAULT_LLM_APP_CONFIG = {
"vision_openai_base_url": DEFAULT_OPENAI_COMPATIBLE_BASE_URL,
"text_llm_provider": DEFAULT_TEXT_LLM_PROVIDER,
"text_openai_model_name": DEFAULT_TEXT_OPENAI_MODEL_NAME,
"text_openai_fast_model_name": DEFAULT_TEXT_OPENAI_FAST_MODEL_NAME,
"text_openai_api_key": "",
"text_openai_base_url": DEFAULT_OPENAI_COMPATIBLE_BASE_URL,
"tavily_api_key": "",
@ -69,6 +71,31 @@ def normalize_openai_compatible_model_name(
return normalized
def resolve_text_model_name(
app_config: dict,
provider: str = DEFAULT_OPENAI_COMPATIBLE_PROVIDER,
*,
prefer_fast: bool = False,
) -> str:
"""Resolve the configured reasoning or fast text model with legacy fallback."""
provider = (provider or DEFAULT_OPENAI_COMPATIBLE_PROVIDER).strip().lower()
reasoning_model = normalize_openai_compatible_model_name(
str(app_config.get(f"text_{provider}_model_name") or ""),
provider=provider,
)
if not reasoning_model and provider == DEFAULT_OPENAI_COMPATIBLE_PROVIDER:
reasoning_model = DEFAULT_TEXT_OPENAI_MODEL_NAME
if not prefer_fast:
return reasoning_model
fast_model = normalize_openai_compatible_model_name(
str(app_config.get(f"text_{provider}_fast_model_name") or ""),
provider=provider,
)
return fast_model or reasoning_model
def get_openai_compatible_ui_values(
full_model_name: str,
default_model: str,

View File

@ -12,6 +12,7 @@ from app.config import config as cfg
from app.config.defaults import (
get_openai_compatible_ui_values,
normalize_openai_compatible_model_name,
resolve_text_model_name,
)
@ -82,11 +83,13 @@ hide_config = true
self.assertEqual(0.95, config_data["app"]["vision_openai_top_p"])
self.assertEqual("openai", config_data["app"]["text_llm_provider"])
self.assertEqual("Pro/zai-org/GLM-5", config_data["app"]["text_openai_model_name"])
self.assertEqual("", config_data["app"]["text_openai_fast_model_name"])
self.assertEqual("https://api.siliconflow.cn/v1", config_data["app"]["text_openai_base_url"])
self.assertEqual(1.0, config_data["app"]["text_openai_temperature"])
self.assertEqual(0.95, config_data["app"]["text_openai_top_p"])
self.assertEqual("Qwen/Qwen3.5-122B-A10B", saved_config["app"]["vision_openai_model_name"])
self.assertEqual("Pro/zai-org/GLM-5", saved_config["app"]["text_openai_model_name"])
self.assertEqual("", saved_config["app"]["text_openai_fast_model_name"])
self.assertTrue(saved_config["app"]["hide_config"])
def test_legacy_indextts2_config_is_migrated_to_indextts_15(self):
@ -127,6 +130,23 @@ hide_config = true
class OpenAICompatibleModelDefaultsTests(unittest.TestCase):
def test_fast_text_model_falls_back_to_reasoning_model(self):
app_config = {
"text_openai_model_name": "reasoning-model",
"text_openai_fast_model_name": "",
}
self.assertEqual(
"reasoning-model",
resolve_text_model_name(app_config, "openai", prefer_fast=True),
)
app_config["text_openai_fast_model_name"] = "fast-model"
self.assertEqual(
"fast-model",
resolve_text_model_name(app_config, "openai", prefer_fast=True),
)
def test_ui_keeps_full_model_name_and_openai_provider(self):
provider, model_name = get_openai_compatible_ui_values(
"Qwen/Qwen3.5-122B-A10B",

View File

@ -183,6 +183,7 @@ class VideoClipParams(BaseModel):
bgm_name: Optional[str] = Field(default="random", description="背景音乐名称")
bgm_type: Optional[str] = Field(default="random", description="背景音乐类型")
bgm_file: Optional[str] = Field(default="", description="背景音乐文件")
sonilo_sfx_enabled: Optional[bool] = Field(default=False, description="是否启用 Sonilo AI 音效(可选功能,默认关闭)")
subtitle_enabled: bool = True
subtitle_mask_enabled: bool = False

View File

@ -409,6 +409,7 @@ class SubtitleAnalyzer:
temperature: float = 0.7,
narration_language: str = "简体中文(中国)",
drama_genre: str = "逆袭/复仇",
narration_word_count: int = 500,
) -> Dict[str, Any]:
"""生成供用户审核修改的解说正文。"""
try:
@ -420,6 +421,7 @@ class SubtitleAnalyzer:
"plot_analysis": plot_analysis,
"subtitle_content": subtitle_content,
"narration_language": narration_language,
"narration_word_count": int(narration_word_count),
},
)
return self._generate_plain_text(prompt, system_prompt, temperature)
@ -964,6 +966,7 @@ def generate_narration_copy(
narration_language: str = "简体中文(中国)",
drama_genre: str = "逆袭/复仇",
prompt_category: str = "short_drama_narration",
narration_word_count: int = 500,
) -> Dict[str, Any]:
"""生成可供用户审核修改的解说正文。"""
analyzer = SubtitleAnalyzer(
@ -982,6 +985,7 @@ def generate_narration_copy(
temperature=temperature,
narration_language=narration_language,
drama_genre=drama_genre,
narration_word_count=narration_word_count,
)

View File

@ -164,11 +164,13 @@ class LLMConfigValidator:
config_prefix = f"text_{provider_name}"
api_key = config.app.get(f'{config_prefix}_api_key')
model_name = config.app.get(f'{config_prefix}_model_name')
fast_model_name = config.app.get(f'{config_prefix}_fast_model_name')
base_url = config.app.get(f'{config_prefix}_base_url')
result["config"] = {
"api_key": "***" if api_key else None,
"model_name": model_name,
"fast_model_name": fast_model_name,
"base_url": base_url
}
@ -241,7 +243,8 @@ class LLMConfigValidator:
f"text_{provider}_model_name"
],
"optional_configs": [
f"text_{provider}_base_url"
f"text_{provider}_base_url",
f"text_{provider}_fast_model_name",
],
"example_models": LLMConfigValidator._get_example_models(provider, "text")
}

View File

@ -292,6 +292,7 @@ class SubtitleAnalyzerAdapter:
temperature: float = 0.7,
narration_language: str = "简体中文(中国)",
drama_genre: str = "逆袭/复仇",
narration_word_count: int = 500,
) -> Dict[str, Any]:
"""Generate editable narration copy before timeline matching."""
try:
@ -303,6 +304,7 @@ class SubtitleAnalyzerAdapter:
"plot_analysis": plot_analysis,
"subtitle_content": subtitle_content,
"narration_language": narration_language,
"narration_word_count": int(narration_word_count),
},
)
narration_copy = self._generate_plain_text(prompt, system_prompt, temperature)

View File

@ -258,8 +258,9 @@ class OpenAICompatibleTextProvider(_OpenAICompatibleBase, TextModelProvider):
response_format: Optional[str],
kwargs: Dict[str, Any],
) -> Dict[str, Any]:
model_name = _normalize_model_name(self.model_name)
generation_kwargs = dict(kwargs)
model_override = generation_kwargs.pop("model", None) or generation_kwargs.pop("model_name", None)
model_name = _normalize_model_name(model_override or self.model_name)
temperature_override = generation_kwargs.pop("temperature", None)
if temperature_override is None and temperature != 1.0:
temperature_override = temperature

View File

@ -149,6 +149,20 @@ class OpenAICompatGenerationOptionTests(unittest.TestCase):
self.assertEqual(65536, options["max_tokens"])
self.assertNotIn("extra_body", options)
def test_text_request_can_override_model_for_fast_tasks(self):
provider = OpenAICompatibleTextProvider(api_key="k", model_name="reasoning-model")
options = provider._build_text_completion_kwargs(
messages=[{"role": "user", "content": "hello"}],
temperature=0.2,
max_tokens=None,
response_format=None,
kwargs={"model": "fast-model", "thinking_level": "off"},
)
self.assertEqual("fast-model", options["model"])
self.assertNotIn("extra_body", options)
def test_build_options_uses_per_model_generation_config(self):
provider = OpenAICompatibleTextProvider(api_key="k", model_name="m")
config.app.update(

View File

@ -24,11 +24,14 @@ class SubtitleAnalyzerAdapterPipelineTests(unittest.TestCase):
temperature=0.7,
narration_language="简体中文(中国)",
drama_genre="家庭伦理",
narration_word_count=800,
)
self.assertEqual("success", result["status"])
self.assertIn("反击", result["narration_copy"])
self.assertIn("家庭伦理", call.call_args.kwargs["prompt"])
self.assertIn("800", call.call_args.kwargs["prompt"])
self.assertNotIn("300-650", call.call_args.kwargs["prompt"])
self.assertNotIn("response_format", call.call_args.kwargs)
def test_generate_narration_copy_can_use_film_tv_prompt_category(self):
@ -49,11 +52,14 @@ class SubtitleAnalyzerAdapterPipelineTests(unittest.TestCase):
temperature=0.7,
narration_language="简体中文(中国)",
drama_genre="悬疑/犯罪",
narration_word_count=1200,
)
self.assertEqual("success", result["status"])
self.assertIn("影视解说正文创作任务", call.call_args.kwargs["prompt"])
self.assertIn("用户选择的影视类型", call.call_args.kwargs["prompt"])
self.assertIn("1200", call.call_args.kwargs["prompt"])
self.assertNotIn("350-750", call.call_args.kwargs["prompt"])
self.assertNotIn("短剧解说正文创作任务", call.call_args.kwargs["prompt"])
def test_film_tv_script_prompts_exclude_intro_outro_and_ads(self):

View File

@ -22,7 +22,14 @@ class NarrationCopyPrompt(ParameterizedPrompt):
model_type=ModelType.TEXT,
output_format=OutputFormat.TEXT,
tags=["影视", "解说文案", "电影解说", "剧情承接", "用户审核"],
parameters=["drama_name", "drama_genre", "plot_analysis", "subtitle_content", "narration_language"],
parameters=[
"drama_name",
"drama_genre",
"plot_analysis",
"subtitle_content",
"narration_language",
"narration_word_count",
],
)
super().__init__(metadata, required_parameters=["drama_name", "plot_analysis", "subtitle_content"])
@ -57,6 +64,11 @@ ${narration_language}
${drama_genre}
</drama_genre>
## 用户要求的文案字数
<narration_word_count>
${narration_word_count}
</narration_word_count>
## 类型写作规则
必须按用户选择的影视类型调整表达重点不要自行改判类型
- 剧情/情感突出人物选择关系裂痕命运压力和情绪余波
@ -81,7 +93,7 @@ ${drama_genre}
4. 每句话只表达一个信息点适合后续按句匹配画面
5. 句子尽量短单句优先 15-35 信息复杂时拆成多句
6. 2-3 句要有明确承接让观众知道为什么从上一幕来到下一幕
7. 总长度控制在 350-750 短素材取下限长素材取上限
7. 总长度${narration_word_count} 字为目标允许上下浮动 10%中日韩语言按非空白字符计数其他语言按单词计数不得再套用固定长度区间
8. 不要使用编号项目符号章节标题或括号说明
## 输出要求

View File

@ -22,7 +22,14 @@ class NarrationCopyPrompt(ParameterizedPrompt):
model_type=ModelType.TEXT,
output_format=OutputFormat.TEXT,
tags=["短剧", "解说文案", "爆款开头", "叙事连续性", "用户审核"],
parameters=["drama_name", "drama_genre", "plot_analysis", "subtitle_content", "narration_language"],
parameters=[
"drama_name",
"drama_genre",
"plot_analysis",
"subtitle_content",
"narration_language",
"narration_word_count",
],
)
super().__init__(metadata, required_parameters=["drama_name", "plot_analysis", "subtitle_content"])
@ -57,6 +64,11 @@ ${narration_language}
${drama_genre}
</drama_genre>
## 用户要求的文案字数
<narration_word_count>
${narration_word_count}
</narration_word_count>
## 类型写作规则
必须按用户选择的短剧类型调整表达重点不要自行改判类型
- 霸总/甜宠突出误会身份差暧昧拉扯守护感和情绪反差
@ -81,7 +93,7 @@ ${drama_genre}
4. 每句话只表达一个信息点适合后续按句匹配画面
5. 句子尽量短单句优先 15-35 信息复杂时拆成多句
6. 2-3 句要有明确因果承接让观众知道为什么从上一幕来到下一幕
7. 总长度控制在 300-650 短素材取下限长素材取上限
7. 总长度${narration_word_count} 字为目标允许上下浮动 10%中日韩语言按非空白字符计数其他语言按单词计数不得再套用固定长度区间
8. 不要使用编号项目符号章节标题或括号说明
## 输出要求

View File

@ -1,31 +1,50 @@
"""
Sonilo (https://sonilo.com) AI 配乐BGM集成 可选功能默认关闭
Sonilo (https://sonilo.com) AI 配乐BGM AI 音效SFX集成
均为可选功能默认关闭
将合成完成的视频未加 BGM上传到 Sonilo API`POST /v1/video-to-music`
根据画面内容与剪辑节奏生成一段背景音乐作为普通音频文件交还给现有的
合成流程混音生成的音乐已获授权可商用以条款为准
配乐BGM将合成完成的视频未加 BGM上传到 Sonilo API
`POST /v1/video-to-music`根据画面内容与剪辑节奏生成一段背景音乐
作为普通音频文件交还给现有的合成流程混音生成的音乐已获授权
可商用以条款为准
设计约束完全不影响现有 BGM 逻辑
* 默认关闭仅当用户在 WebUI 中把背景音乐来源切换为 Sonilo且配置了
音效SFX将合成完成的视频上传到 Sonilo API
`POST /v1/video-to-sfx`根据画面内容生成贴合画面的音效该接口是
异步任务提交后返回 task_id轮询 `GET /v1/tasks/{task_id}` 直到终态
成功后从预签名 URL 下载音效音频再用 ffmpeg 混在成片现有音轨之下
解说配音在后续合成步骤中单独混入音量策略不受影响生成的音效为
免版税素材
设计约束完全不影响现有合成逻辑
* 默认关闭配乐仅当用户在 WebUI 中把背景音乐来源切换为 Sonilo 时启用
音效仅当用户勾选 "AI 音效Sonilo" 时启用两者都要求配置了
Sonilo API Keyconfig.toml `sonilo_api_key`或环境变量
`SONILO_API_KEY` 兜底时才会启用
* 本模块只负责生成音频文件音量淡出循环与混音全部复用
`SONILO_API_KEY` 兜底
* 配乐模块只负责生成音频文件音量淡出循环与混音全部复用
app/services/generate_video.py 中现有的音频处理逻辑解说配音的
音量压制策略保持不变
* 任何失败超时HTTP 错误流中断时长超限都只记录日志并返回
空字符串由调用方回退到现有的 BGM 逻辑绝不中断成片任务
* 上传属于计费操作上传前先用 ffprobe 在本地校验视频时长接口
目前拒绝超过 6 分钟的视频避免白传一次注定被拒绝的成片
* 任何失败超时HTTP 错误流中断时长超限混音失败都只记录
日志并返回空字符串由调用方回退到现有逻辑绝不中断成片任务
* 上传属于计费操作上传前先用 ffprobe 在本地校验视频时长配乐接口
目前拒绝超过 6 分钟的视频音效接口拒绝超过 3 分钟的视频避免
白传一次注定被拒绝的成片
接口返回 NDJSON 事件流`audio_chunk`base64 音频分片 stream_index
分组`title``complete`成功终止事件 `error`失败终止事件
进度事件与无法解析的行一律忽略生成的音频为 AAC 编码的 .m4a 文件
配乐接口返回 NDJSON 事件流`audio_chunk`base64 音频分片
stream_index 分组`title``complete`成功终止事件 `error`
失败终止事件进度事件与无法解析的行一律忽略生成的音频为 AAC
编码的 .m4a 文件
音效接口为异步任务管线`POST /v1/video-to-sfx` 受理后即计费并返回
`{"task_id": ...}``GET /v1/tasks/{task_id}` 返回
`{"status": "succeeded"/"failed"/..., "audio": {"url": ...}, "error": ...,
"refunded": ...}`结果地址是预签名 URL下载时绝不能携带 API Key
配置config.toml [app]
sonilo_api_key = "..." # 必填,启用开关
sonilo_api_key = "..." # 必填,启用开关(配乐与音效共用)
# sonilo_base_url = "https://api.sonilo.com"
# sonilo_timeout_seconds = 600
# sonilo_bgm_prompt = "" # 可选:配乐风格提示
# sonilo_sfx_prompt = "" # 可选:音效风格提示
# sonilo_sfx_volume = 0.6 # 音效混入原声之下的音量0-2]
"""
import base64
@ -33,6 +52,7 @@ import binascii
import json
import os
import subprocess
import time
from typing import Iterable, Optional
import requests
@ -42,12 +62,24 @@ from app.config import config
DEFAULT_BASE_URL = "https://api.sonilo.com"
VIDEO_TO_MUSIC_PATH = "/v1/video-to-music"
VIDEO_TO_SFX_PATH = "/v1/video-to-sfx"
TASKS_PATH = "/v1/tasks"
# 后端生成接口的读超时约为 600 秒。生成一旦开始就会计费,客户端过早超时
# 只会浪费一次已经付费的请求,所以默认读超时与后端保持一致,并允许覆盖。
DEFAULT_TIMEOUT_SECONDS = 600
_CONNECT_TIMEOUT_SECONDS = 15
# 接口目前拒绝超过 6 分钟的视频;上传前先在本地校验时长。
# 轮询任务状态是免费且幂等的 GET单次请求用短读超时即可。
_POLL_READ_TIMEOUT_SECONDS = 30
_SFX_POLL_INTERVAL_SECONDS = 5.0
# 测试接缝:单测里替换为 no-op避免真实等待。
_sleep = time.sleep
# 配乐接口目前拒绝超过 6 分钟的视频;上传前先在本地校验时长。
MAX_VIDEO_DURATION_SECONDS = 360
# 音效接口目前拒绝超过 3 分钟的视频。
MAX_SFX_VIDEO_DURATION_SECONDS = 180
# 音效混入原声之下的默认音量(解说配音在后续合成步骤中以 1.0 混入,
# 音效保持在其之下)。可通过 sonilo_sfx_volume 配置覆盖。
DEFAULT_SFX_VOLUME = 0.6
class SoniloError(Exception):
@ -65,6 +97,10 @@ def is_enabled() -> bool:
return bool(get_api_key())
def _get_base_url() -> str:
return str(config.app.get("sonilo_base_url", "") or DEFAULT_BASE_URL).rstrip("/")
def generate_bgm(video_path: str, save_path: str) -> str:
"""
上传合成完成的视频未加 BGM Sonilo生成配乐并保存到
@ -182,9 +218,7 @@ def _get_timeout_seconds() -> float:
def _request_video_to_music(video_path: str) -> bytes:
base_url = str(config.app.get("sonilo_base_url", "") or DEFAULT_BASE_URL).rstrip(
"/"
)
base_url = _get_base_url()
timeout_seconds = _get_timeout_seconds()
prompt = str(config.app.get("sonilo_bgm_prompt", "") or "").strip()
@ -271,3 +305,322 @@ def _consume_ndjson_stream(lines: Iterable[str]) -> bytes:
raise SoniloError("Sonilo 事件流已完成但未返回音频数据")
first_index = sorted(streams)[0]
return bytes(streams[first_index])
# ---------- AI 音效SFX可选功能默认关闭 ----------
def apply_sfx(video_path: str, output_path: str) -> str:
"""
为合成完成的视频生成 Sonilo 音效并用 ffmpeg 混在现有音轨之下
输出新视频到 `output_path`视频流直接复制不重编码画面
成功时返回输出视频路径任何失败都返回空字符串由调用方沿用
原视频本函数绝不抛出异常 音效问题绝不能中断成片任务
"""
sfx_audio_path = os.path.splitext(output_path)[0] + ".m4a"
if not generate_sfx(video_path, sfx_audio_path):
return ""
return _mix_sfx_under_original(video_path, sfx_audio_path, output_path)
def generate_sfx(video_path: str, save_path: str) -> str:
"""
上传合成完成的视频到 Sonilo生成音效音频并保存到 `save_path`.m4a
成功时返回音频文件路径任何失败都返回空字符串 generate_bgm
的约定一致本函数绝不抛出异常
"""
if not is_enabled():
logger.warning("Sonilo 音效已跳过: 未配置 API Key")
return ""
if not video_path or not os.path.isfile(video_path):
logger.warning(f"Sonilo 音效已跳过: 视频文件不存在: {video_path}")
return ""
# 任务受理即计费,先在本地校验时长,避免白传一次注定被拒绝的成片。
duration = _probe_video_duration(video_path)
if duration and duration > MAX_SFX_VIDEO_DURATION_SECONDS:
logger.warning(
f"Sonilo 音效已跳过: 视频时长 {duration:.1f}s 超过接口上限 "
f"{MAX_SFX_VIDEO_DURATION_SECONDS}s"
)
return ""
try:
audio = _request_video_to_sfx(video_path)
except Exception as e:
logger.error(f"Sonilo 音效生成失败: {str(e)}")
return ""
try:
with open(save_path, "wb") as f:
f.write(audio)
except OSError as e:
logger.error(f"Sonilo 音效文件保存失败: {str(e)}")
return ""
logger.success(f"Sonilo 音效已生成: {save_path}")
return save_path
def _request_video_to_sfx(video_path: str) -> bytes:
"""提交音效任务、轮询到终态、下载结果音频。失败抛出 SoniloError。"""
task_id = _submit_sfx_task(video_path)
body = _poll_sfx_task(task_id)
return _download_sfx_audio(_extract_sfx_audio_url(body, task_id))
def _submit_sfx_task(video_path: str) -> str:
"""POST /v1/video-to-sfx受理后返回 task_id受理即计费不做重试"""
timeout_seconds = _get_timeout_seconds()
prompt = str(config.app.get("sonilo_sfx_prompt", "") or "").strip()
data: Optional[dict] = {"prompt": prompt} if prompt else None
headers = {"Authorization": f"Bearer {get_api_key()}"}
logger.info(f"正在提交 Sonilo 音效任务, 视频: {video_path}")
try:
with open(video_path, "rb") as video_file:
files = {
"video": (os.path.basename(video_path), video_file, "video/mp4"),
}
response = requests.post(
f"{_get_base_url()}{VIDEO_TO_SFX_PATH}",
headers=headers,
data=data,
files=files,
timeout=(_CONNECT_TIMEOUT_SECONDS, timeout_seconds),
)
except requests.exceptions.Timeout as exc:
raise SoniloError(
f"Sonilo 音效任务提交超时 ({timeout_seconds:.0f}s)"
) from exc
except requests.exceptions.RequestException as exc:
raise SoniloError(f"Sonilo 音效任务提交失败: {str(exc)}") from exc
if response.status_code >= 400:
body = response.content.decode("utf-8", errors="replace")
raise SoniloError(_http_error_message(response.status_code, body))
try:
task_id = response.json().get("task_id")
except (ValueError, AttributeError):
task_id = None
if not task_id:
raise SoniloError("Sonilo 音效任务已受理但未返回 task_id")
task_id = str(task_id)
# 受理即计费;先把 task_id 落进日志,后续轮询失败时仍有据可查。
logger.info(f"Sonilo 音效任务已提交: {task_id}")
return task_id
def _poll_sfx_task(task_id: str) -> dict:
"""
轮询 GET /v1/tasks/{task_id} 直到任务终态succeeded/failed或超时
succeeded 时返回任务体failed / 超时 / 不可恢复的 HTTP 错误抛出
SoniloError轮询是免费且幂等的 GET网络抖动与 5xx 不该报废一次
已计费的任务在截止时间内继续重试
"""
headers = {"Authorization": f"Bearer {get_api_key()}"}
timeout_seconds = _get_timeout_seconds()
deadline = time.monotonic() + timeout_seconds
while True:
response = None
try:
response = requests.get(
f"{_get_base_url()}{TASKS_PATH}/{task_id}",
headers=headers,
timeout=(_CONNECT_TIMEOUT_SECONDS, _POLL_READ_TIMEOUT_SECONDS),
)
except requests.exceptions.RequestException as exc:
logger.warning(f"Sonilo 音效任务查询失败(将重试): {str(exc)}")
if response is not None:
if response.status_code >= 500:
logger.warning(
f"Sonilo 音效任务查询返回 {response.status_code}(将重试)"
)
elif response.status_code >= 400:
body = response.content.decode("utf-8", errors="replace")
raise SoniloError(
f"{_http_error_message(response.status_code, body)}"
f"(任务已提交, task_id: {task_id}"
)
else:
try:
body = response.json()
except ValueError:
body = None
if isinstance(body, dict):
status = body.get("status")
if status == "succeeded":
return body
if status == "failed":
raise SoniloError(_task_failure_message(body, task_id))
# 非终态pending / processing 等)继续等待。
if time.monotonic() >= deadline:
raise SoniloError(
f"等待 Sonilo 音效任务超时 ({timeout_seconds:.0f}s), "
f"task_id: {task_id}"
)
_sleep(_SFX_POLL_INTERVAL_SECONDS)
def _task_failure_message(body: dict, task_id: str) -> str:
err = body.get("error")
if isinstance(err, dict):
message = err.get("message") or err.get("code") or "生成失败"
elif isinstance(err, str) and err:
message = err
else:
message = "生成失败"
refund_note = ",费用已退还" if body.get("refunded") is True else ""
return f"Sonilo 音效生成失败: {message}task_id: {task_id}{refund_note}"
def _extract_sfx_audio_url(body: dict, task_id: str) -> str:
audio = body.get("audio")
if isinstance(audio, dict):
url = audio.get("url")
if isinstance(url, str) and url:
return url
raise SoniloError(
f"Sonilo 音效任务成功但未返回音频结果 (task_id: {task_id})"
)
def _download_sfx_audio(url: str) -> bytes:
"""下载任务结果音频。结果地址是预签名 URL自带鉴权 ——
绝不能把 API Key 发给存储域名因此这里不带任何鉴权头"""
try:
response = requests.get(
url, timeout=(_CONNECT_TIMEOUT_SECONDS, _get_timeout_seconds())
)
except requests.exceptions.RequestException as exc:
raise SoniloError(f"Sonilo 音效结果下载失败: {str(exc)}") from exc
if response.status_code >= 400:
raise SoniloError(
f"Sonilo 音效结果下载失败 (HTTP {response.status_code})"
)
if not response.content:
raise SoniloError("Sonilo 音效结果为空")
return response.content
def _get_sfx_volume() -> float:
"""音效混入原声之下的音量。非法值或 <=0 回退默认值,上限 2.0。"""
try:
volume = float(config.app.get("sonilo_sfx_volume", DEFAULT_SFX_VOLUME))
except (TypeError, ValueError):
return DEFAULT_SFX_VOLUME
if volume <= 0:
return DEFAULT_SFX_VOLUME
return min(volume, 2.0)
def _get_ffmpeg_binary() -> str:
"""与 generate_video 保持一致的 ffmpeg 查找逻辑(环境变量优先)。"""
for env_name in ("NARRATO_FFMPEG_EXE", "IMAGEIO_FFMPEG_EXE"):
candidate = os.environ.get(env_name, "").strip()
if candidate and os.path.isfile(candidate):
return candidate
try:
import imageio_ffmpeg
candidate = imageio_ffmpeg.get_ffmpeg_exe()
if candidate and os.path.isfile(candidate):
return candidate
except Exception:
pass
return "ffmpeg"
def _probe_has_audio_stream(video_path: str) -> bool:
"""尽力而为地探测视频是否带音轨。探测失败按无音轨处理。"""
try:
result = subprocess.run(
[
_get_ffprobe_binary(),
"-v",
"quiet",
"-print_format",
"json",
"-show_streams",
"-select_streams",
"a",
video_path,
],
capture_output=True,
timeout=30,
)
except (OSError, subprocess.TimeoutExpired):
return False
if result.returncode != 0:
return False
try:
return bool(json.loads(result.stdout).get("streams"))
except (json.JSONDecodeError, AttributeError, TypeError, ValueError):
return False
def _mix_sfx_under_original(
video_path: str, sfx_audio_path: str, output_path: str
) -> str:
"""
ffmpeg 把音效混在成片现有音轨之下音效音量默认 0.6原声音量
不变视频流直接复制不重编码成片没有音轨时音效直接作为音轨
写入成功返回 output_path任何失败返回空字符串
"""
volume = _get_sfx_volume()
has_audio = _probe_has_audio_stream(video_path)
if has_audio:
filter_complex = (
f"[1:a]volume={volume}[sfx];"
"[0:a][sfx]amix=inputs=2:duration=first:"
"dropout_transition=0:normalize=0[aout]"
)
else:
filter_complex = f"[1:a]volume={volume}[aout]"
cmd = [
_get_ffmpeg_binary(),
"-y",
"-i",
video_path,
"-i",
sfx_audio_path,
"-filter_complex",
filter_complex,
"-map",
"0:v",
"-map",
"[aout]",
"-c:v",
"copy",
"-c:a",
"aac",
"-b:a",
"192k",
]
if not has_audio:
cmd.append("-shortest")
cmd.append(output_path)
logger.info(f"正在混入 Sonilo 音效 (音量 {volume}): {output_path}")
try:
result = subprocess.run(cmd, capture_output=True, timeout=300)
except (OSError, subprocess.TimeoutExpired) as e:
logger.error(f"Sonilo 音效混音失败: {str(e)}")
return ""
if result.returncode != 0:
stderr_tail = (result.stderr or b"").decode("utf-8", errors="replace")[-500:]
logger.error(f"Sonilo 音效混音失败 (ffmpeg 退出码 {result.returncode}): {stderr_tail}")
return ""
logger.success(f"Sonilo 音效已混入: {output_path}")
return output_path

View File

@ -10,6 +10,8 @@ from typing import Any
from loguru import logger
from app.config import config
from app.config.defaults import resolve_text_model_name
from app.services.llm.manager import LLMServiceManager
from app.services.llm.migration_adapter import _run_async_safely
from app.services.llm.unified_service import UnifiedLLMService
@ -174,12 +176,16 @@ def correct_srt_content(
provider: str = "",
api_key: str = "",
base_url: str = "",
model_name: str = "",
temperature: float = 0.1,
) -> str:
blocks = parse_srt_blocks(srt_content)
_ensure_llm_providers_registered()
logger.info(f"开始校准字幕,共 {len(blocks)}")
resolved_model_name = str(
model_name or resolve_text_model_name(config.app, provider, prefer_fast=True)
).strip()
logger.info(f"开始使用高效率模型 {resolved_model_name} 校准字幕,共 {len(blocks)}")
prompt = _build_correction_prompt(blocks)
raw_output = _run_async_safely(
UnifiedLLMService.generate_text,
@ -190,6 +196,8 @@ def correct_srt_content(
response_format="json",
api_key=api_key,
api_base=base_url,
model=resolved_model_name,
thinking_level="off",
)
corrections = _parse_corrections(raw_output, {block.order for block in blocks})
corrected_srt = _render_srt(blocks, corrections)
@ -215,6 +223,7 @@ def correct_subtitle_file(
provider: str = "",
api_key: str = "",
base_url: str = "",
model_name: str = "",
temperature: float = 0.1,
) -> str:
if not subtitle_file or not os.path.isfile(subtitle_file):
@ -226,6 +235,7 @@ def correct_subtitle_file(
provider=provider,
api_key=api_key,
base_url=base_url,
model_name=model_name,
temperature=temperature,
)
return write_srt_file(corrected_srt, output_file)

View File

@ -11,6 +11,7 @@ from typing import Any, Callable
from loguru import logger
from app.config import config
from app.config.defaults import resolve_text_model_name
from app.services.llm.migration_adapter import _run_async_safely
from app.services.llm.unified_service import UnifiedLLMService
from app.services.subtitle_corrector import (
@ -151,6 +152,7 @@ def _translate_chunk(
provider: str,
api_key: str,
base_url: str,
model_name: str,
temperature: float,
max_repair_attempts: int,
) -> dict[int, str]:
@ -189,6 +191,8 @@ def _translate_chunk(
response_format="json",
api_key=api_key,
api_base=base_url,
model=model_name,
thinking_level="off",
)
last_output = str(raw_output or "")
try:
@ -243,6 +247,7 @@ def translate_srt_content(
provider: str = "",
api_key: str = "",
base_url: str = "",
model_name: str = "",
temperature: float = 0.2,
batch_size: int | None = None,
max_workers: int | None = None,
@ -251,6 +256,9 @@ def translate_srt_content(
target_language = str(target_language or "").strip() or "中文"
blocks = parse_srt_blocks(srt_content)
_ensure_llm_providers_registered()
resolved_model_name = str(
model_name or resolve_text_model_name(config.app, provider, prefer_fast=True)
).strip()
resolved_batch_size = _resolve_batch_size(batch_size)
chunks = _split_blocks(blocks, resolved_batch_size)
@ -260,7 +268,8 @@ def translate_srt_content(
logger.info(
f"开始批量翻译字幕: 共 {total_blocks} 条, {total_chunks} 批, "
f"每批最多 {resolved_batch_size} 条, 并发 {resolved_max_workers}, 目标语言: {target_language}"
f"每批最多 {resolved_batch_size} 条, 并发 {resolved_max_workers}, "
f"目标语言: {target_language}, 高效率模型: {resolved_model_name}"
)
translations: dict[int, str] = {}
@ -282,6 +291,7 @@ def translate_srt_content(
provider=provider,
api_key=api_key,
base_url=base_url,
model_name=resolved_model_name,
temperature=temperature,
max_repair_attempts=DEFAULT_MAX_REPAIR_ATTEMPTS,
)
@ -301,6 +311,7 @@ def translate_srt_content(
provider=provider,
api_key=api_key,
base_url=base_url,
model_name=resolved_model_name,
temperature=temperature,
max_repair_attempts=DEFAULT_MAX_REPAIR_ATTEMPTS,
)
@ -347,6 +358,7 @@ def translate_subtitle_file(
provider: str = "",
api_key: str = "",
base_url: str = "",
model_name: str = "",
temperature: float = 0.2,
batch_size: int | None = None,
max_workers: int | None = None,
@ -362,6 +374,7 @@ def translate_subtitle_file(
provider=provider,
api_key=api_key,
base_url=base_url,
model_name=model_name,
temperature=temperature,
batch_size=batch_size,
max_workers=max_workers,

View File

@ -236,6 +236,24 @@ def _resolve_bgm_path(task_id: str, params: VideoClipParams, combined_video_path
)
def _apply_sonilo_sfx(task_id: str, params: VideoClipParams, combined_video_path: str) -> str:
"""为合并后的成片混入 Sonilo AI 音效(可选功能,默认关闭)。
仅当 params.sonilo_sfx_enabled True 时启用把合并后的成片上传到
Sonilo API 生成音效再用 ffmpeg 混在现有音轨之下返回新视频路径
解说配音在后续 merge_materials 中单独混入音量策略不受影响任何
失败都只记录日志并沿用原视频绝不中断成片任务
"""
if not getattr(params, "sonilo_sfx_enabled", False):
return combined_video_path
output_path = path.join(utils.task_dir(task_id), "merger_sfx.mp4")
sfx_video_path = sonilo.apply_sfx(combined_video_path, output_path)
if sfx_video_path:
return sfx_video_path
logger.warning("Sonilo 音效不可用,继续使用未加音效的成片")
return combined_video_path
def _transcribe_final_video(task_id: str, video_path: str, params: VideoClipParams) -> str:
"""Transcribe the fully merged video into an SRT file."""
from app.services import fun_asr_subtitle
@ -542,6 +560,9 @@ def start_subclip(task_id: str, params: VideoClipParams, subclip_path_videos: di
)
logger.info(f"\n\n## 6. 最后一步: 合并字幕/BGM/配音/视频 -> {merge_output_video_path}")
# 可选功能,默认关闭:混入 Sonilo AI 音效(失败时沿用原视频)
combined_video_path = _apply_sonilo_sfx(task_id, params, combined_video_path)
# bgm_path = '/Users/apple/Desktop/home/NarratoAI/resource/songs/bgm.mp3'
bgm_path = _resolve_bgm_path(task_id, params, combined_video_path)
@ -869,6 +890,9 @@ def start_subclip_unified(task_id: str, params: VideoClipParams):
ffmpeg_progress=0,
)
# 可选功能,默认关闭:混入 Sonilo AI 音效(失败时沿用原视频)
combined_video_path = _apply_sonilo_sfx(task_id, params, combined_video_path)
bgm_path = _resolve_bgm_path(task_id, params, combined_video_path)
# 获取优化的音量配置

View File

@ -27,6 +27,14 @@ class SubtitleCorrectorTests(unittest.TestCase):
}
with (
mock.patch.dict(
corrector.config.app,
{
"text_openai_model_name": "reasoning-model",
"text_openai_fast_model_name": "fast-subtitle-model",
},
clear=False,
),
mock.patch("app.services.subtitle_corrector._ensure_llm_providers_registered"),
mock.patch(
"app.services.subtitle_corrector._run_async_safely",
@ -49,6 +57,8 @@ class SubtitleCorrectorTests(unittest.TestCase):
self.assertEqual("openai", call_kwargs["provider"])
self.assertEqual("sk-test", call_kwargs["api_key"])
self.assertEqual("https://llm.example/v1", call_kwargs["api_base"])
self.assertEqual("fast-subtitle-model", call_kwargs["model"])
self.assertEqual("off", call_kwargs["thinking_level"])
self.assertEqual("json", call_kwargs["response_format"])
self.assertIn("多语言字幕校对员", call_kwargs["system_prompt"])
self.assertIn("保持原语言", call_kwargs["prompt"])

View File

@ -48,6 +48,14 @@ class SubtitleTranslatorTests(unittest.TestCase):
}
with (
mock.patch.dict(
translator.config.app,
{
"text_openai_model_name": "reasoning-model",
"text_openai_fast_model_name": "fast-subtitle-model",
},
clear=False,
),
mock.patch("app.services.subtitle_translator._ensure_llm_providers_registered"),
mock.patch(
"app.services.subtitle_translator._run_async_safely",
@ -71,6 +79,8 @@ class SubtitleTranslatorTests(unittest.TestCase):
self.assertEqual("openai", call_kwargs["provider"])
self.assertEqual("sk-test", call_kwargs["api_key"])
self.assertEqual("https://llm.example/v1", call_kwargs["api_base"])
self.assertEqual("fast-subtitle-model", call_kwargs["model"])
self.assertEqual("off", call_kwargs["thinking_level"])
self.assertEqual("json", call_kwargs["response_format"])
self.assertIn("专业字幕翻译员", call_kwargs["system_prompt"])
self.assertIn("翻译为中文", call_kwargs["prompt"])

View File

@ -0,0 +1,81 @@
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from app.services import voice
class FakeResponse:
def __init__(self, *, status_code=200, content=b"", payload=None):
self.status_code = status_code
self.content = content
self._payload = payload or {}
self.text = "OK"
def json(self):
return self._payload
class VoxCPM2TtsTests(unittest.TestCase):
def setUp(self):
self.original_config = dict(voice.config.voxcpm_2b)
self.original_proxy = dict(voice.config.proxy)
def tearDown(self):
voice.config.voxcpm_2b.clear()
voice.config.voxcpm_2b.update(self.original_config)
voice.config.proxy.clear()
voice.config.proxy.update(self.original_proxy)
def test_voice_design_sends_control_and_downloads_wav(self):
voice.config.voxcpm_2b.clear()
voice.config.voxcpm_2b.update({
"api_url": "http://127.0.0.1:7863/v1/audio/speech",
"mode": "design", "control": "温暖自然的年轻女声",
"cfg_value": 2.0, "inference_timesteps": 10,
"normalize": True, "denoise": False, "output_48k": True,
"context_aware": True, "streaming": False,
})
voice.config.proxy.clear()
with tempfile.TemporaryDirectory() as temp_dir:
output = Path(temp_dir) / "output.wav"
with (
patch("app.services.voice.requests.post", return_value=FakeResponse(payload={"downloads": {"wav": "/download/result.wav"}})) as post,
patch("app.services.voice.requests.get", return_value=FakeResponse(content=b"wav-2b")) as get,
patch("app.services.voice.get_audio_duration_from_file", return_value=2.0),
):
result = voice.voxcpm2_tts(" 高质量旁白。 ", "voxcpm_2b:design", str(output))
self.assertIsNotNone(result)
self.assertEqual(output.read_bytes(), b"wav-2b")
self.assertEqual(post.call_args.args[0], "http://127.0.0.1:7863/tts")
self.assertIsNone(post.call_args.kwargs["files"])
self.assertEqual(post.call_args.kwargs["data"]["control"], "温暖自然的年轻女声")
self.assertEqual(post.call_args.kwargs["data"]["output_48k"], "true")
self.assertEqual(get.call_args.args[0], "http://127.0.0.1:7863/download/result.wav")
def test_clone_uploads_reference_audio(self):
voice.config.voxcpm_2b.clear()
voice.config.voxcpm_2b.update({
"api_url": "http://127.0.0.1:7863/tts/batch",
"mode": "clone", "prompt_text": "参考音频文本",
})
voice.config.proxy.clear()
with tempfile.TemporaryDirectory() as temp_dir:
reference = Path(temp_dir) / "reference.wav"
output = Path(temp_dir) / "output.wav"
reference.write_bytes(b"reference")
with (
patch("app.services.voice.requests.post", return_value=FakeResponse(payload={"downloads": {"wav": "/download/result.wav"}})) as post,
patch("app.services.voice.requests.get", return_value=FakeResponse(content=b"wav")),
patch("app.services.voice.get_audio_duration_from_file", return_value=1.0),
):
result = voice.voxcpm2_tts("克隆测试", f"voxcpm_2b:{reference}", str(output))
self.assertIsNotNone(result)
self.assertEqual(post.call_args.args[0], "http://127.0.0.1:7863/tts")
self.assertIn("reference_audio", post.call_args.kwargs["files"])
self.assertEqual(post.call_args.kwargs["data"]["prompt_text"], "参考音频文本")
if __name__ == "__main__":
unittest.main()

View File

@ -0,0 +1,83 @@
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from app.services import voice
class FakeResponse:
def __init__(self, *, status_code=200, content=b"", payload=None):
self.status_code = status_code
self.content = content
self._payload = payload or {}
self.text = "OK"
def json(self):
return self._payload
class VoxCPMTtsTests(unittest.TestCase):
def setUp(self):
self.original_config = dict(voice.config.voxcpm_05b)
self.original_proxy = dict(voice.config.proxy)
def tearDown(self):
voice.config.voxcpm_05b.clear()
voice.config.voxcpm_05b.update(self.original_config)
voice.config.proxy.clear()
voice.config.proxy.update(self.original_proxy)
def test_uploads_prompt_audio_and_downloads_generated_wav(self):
voice.config.voxcpm_05b.clear()
voice.config.voxcpm_05b.update({
"api_url": "http://127.0.0.1:7864/v1/audio/speech",
"prompt_text": "参考文本",
"cfg_value": 2.1,
"inference_timesteps": 12,
"max_length": 2048,
"normalize": True,
"denoise": False,
})
voice.config.proxy.clear()
with tempfile.TemporaryDirectory() as temp_dir:
reference = Path(temp_dir) / "reference.wav"
output = Path(temp_dir) / "output.wav"
reference.write_bytes(b"reference")
with (
patch("app.services.voice.requests.post", return_value=FakeResponse(payload={"audio_url": "/outputs/result.wav"})) as post,
patch("app.services.voice.requests.get", return_value=FakeResponse(content=b"wav-bytes")) as get,
patch("app.services.voice.get_audio_duration_from_file", return_value=1.5),
):
result = voice.voxcpm_tts(" 测试文本。 ", f"voxcpm_05b:{reference}", str(output))
self.assertIsNotNone(result)
self.assertEqual(output.read_bytes(), b"wav-bytes")
self.assertEqual(post.call_args.args[0], "http://127.0.0.1:7864/tts")
self.assertEqual(post.call_args.kwargs["data"], {
"text": "测试文本。", "prompt_text": "参考文本", "cfg_value": 2.1,
"inference_timesteps": 12, "max_length": 2048,
"normalize": "true", "denoise": "false",
})
self.assertIn("prompt_audio", post.call_args.kwargs["files"])
self.assertEqual(get.call_args.args[0], "http://127.0.0.1:7864/outputs/result.wav")
def test_default_voice_does_not_upload_audio(self):
voice.config.voxcpm_05b.clear()
voice.config.voxcpm_05b.update({"api_url": "http://127.0.0.1:7864"})
voice.config.proxy.clear()
with tempfile.TemporaryDirectory() as temp_dir:
output = Path(temp_dir) / "output.wav"
with (
patch("app.services.voice.requests.post", return_value=FakeResponse(payload={"audio_url": "/outputs/result.wav"})) as post,
patch("app.services.voice.requests.get", return_value=FakeResponse(content=b"wav")),
patch("app.services.voice.get_audio_duration_from_file", return_value=1.0),
):
result = voice.voxcpm_tts("默认音色", "voxcpm_05b:default", str(output))
self.assertIsNotNone(result)
self.assertIsNone(post.call_args.kwargs["files"])
if __name__ == "__main__":
unittest.main()

View File

@ -1313,6 +1313,14 @@ def tts(
if tts_engine == config.OMNIVOICE_ENGINE:
logger.info("分发到 OmniVoice")
return omnivoice_tts(text, voice_name, voice_file, speed=voice_rate)
if tts_engine == config.VOXCPM_ENGINE:
logger.info("分发到 VoxCPM-0.5B")
return voxcpm_tts(text, voice_name, voice_file)
if tts_engine == config.VOXCPM2_ENGINE:
logger.info("分发到 VoxCPM-2B")
return voxcpm2_tts(text, voice_name, voice_file)
if tts_engine == "doubaotts":
logger.info("分发到豆包语音 TTS")
@ -1803,6 +1811,8 @@ def tts_multiple(task_id: str, list_script: list, voice_name: str, voice_rate: f
config.INDEXTTS_MACOS_ENGINE,
config.INDEXTTS2_ENGINE,
config.OMNIVOICE_ENGINE,
config.VOXCPM_ENGINE,
config.VOXCPM2_ENGINE,
) else ".mp3"
for item in list_script:
@ -1838,6 +1848,8 @@ def tts_multiple(task_id: str, list_script: list, voice_name: str, voice_rate: f
config.INDEXTTS_MACOS_ENGINE,
config.INDEXTTS2_ENGINE,
config.OMNIVOICE_ENGINE,
config.VOXCPM_ENGINE,
config.VOXCPM2_ENGINE,
)
or tts_engine == "doubaotts"
):
@ -2299,6 +2311,20 @@ def parse_omnivoice_voice(voice_name: str) -> str:
return voice_name
def parse_voxcpm_voice(voice_name: str) -> str:
"""解析 VoxCPM-0.5B 的可选参考音频路径。"""
if isinstance(voice_name, str) and voice_name.startswith(config.VOXCPM_VOICE_PREFIX):
return voice_name[len(config.VOXCPM_VOICE_PREFIX):]
return voice_name
def parse_voxcpm2_voice(voice_name: str) -> str:
"""解析 VoxCPM-2B 的模式或参考音频路径。"""
if isinstance(voice_name, str) and voice_name.startswith(config.VOXCPM2_VOICE_PREFIX):
return voice_name[len(config.VOXCPM2_VOICE_PREFIX):]
return voice_name
def indextts_tts(text: str, voice_name: str, voice_file: str, speed: float = 1.0) -> Union[SubMaker, None]:
"""
使用 IndexTTS-1.5 API 进行零样本语音克隆
@ -2742,6 +2768,144 @@ def indextts2_tts(text: str, voice_name: str, voice_file: str) -> Union[SubMaker
return None
def _normalize_voxcpm_api_url(api_url: str) -> str:
api_url = (api_url or "http://127.0.0.1:7864").strip().rstrip("/")
if api_url.endswith("/v1/audio/speech"):
api_url = api_url[:-len("/v1/audio/speech")]
if api_url.endswith("/tts"):
return api_url
return f"{api_url}/tts"
def voxcpm_tts(text: str, voice_name: str, voice_file: str) -> Union[SubMaker, None]:
"""使用 VoxCPM-0.5B-Pack 的 multipart /tts 接口生成 WAV。"""
voxcpm_config = getattr(config, "voxcpm_05b", {}) or {}
api_url = _normalize_voxcpm_api_url(voxcpm_config.get("api_url", "http://127.0.0.1:7864"))
reference_audio = parse_voxcpm_voice(voice_name)
if reference_audio in ("", "default") or not os.path.isfile(reference_audio):
reference_audio = voxcpm_config.get("reference_audio", "") or ""
if reference_audio and not os.path.isfile(reference_audio):
logger.error(f"VoxCPM-0.5B 参考音频文件不存在: {reference_audio}")
return None
data = {"text": text.strip()}
optional_fields = {
"prompt_text": voxcpm_config.get("prompt_text"),
"cfg_value": voxcpm_config.get("cfg_value"),
"inference_timesteps": voxcpm_config.get("inference_timesteps"),
"max_length": voxcpm_config.get("max_length"),
}
for key, value in optional_fields.items():
if value not in (None, ""):
data[key] = value
for key in ("normalize", "denoise"):
if key in voxcpm_config:
data[key] = str(bool(voxcpm_config[key])).lower()
proxies = _get_configured_proxies()
for attempt in range(3):
files = {}
try:
if reference_audio:
files["prompt_audio"] = open(reference_audio, "rb")
response = requests.post(api_url, data=data, files=files or None, proxies=proxies, timeout=300)
if response.status_code == 200:
result = response.json()
audio_url = result.get("audio_url", "") if isinstance(result, dict) else ""
if audio_url:
audio_response = requests.get(urljoin(api_url, audio_url), proxies=proxies, timeout=180)
if audio_response.status_code == 200:
with open(voice_file, "wb") as output:
output.write(audio_response.content)
if os.path.getsize(voice_file) > 0:
sub_maker = new_sub_maker()
duration = get_audio_duration_from_file(voice_file)
duration_ms = int(duration * 1000) if duration > 0 else max(1000, len(text) * 200)
add_subtitle_event(sub_maker, 0, duration_ms * 10000, text)
return sub_maker
logger.error(f"VoxCPM-0.5B API 响应中没有有效音频地址: {result}")
else:
logger.error(f"VoxCPM-0.5B API 调用失败: {response.status_code} - {response.text}")
except (ValueError, requests.exceptions.RequestException) as exc:
logger.error(f"VoxCPM-0.5B TTS 请求失败 (尝试 {attempt + 1}/3): {exc}")
finally:
for file_obj in files.values():
file_obj.close()
if attempt < 2:
time.sleep(2)
return None
def _normalize_voxcpm2_api_url(api_url: str) -> str:
api_url = (api_url or "http://127.0.0.1:7863").strip().rstrip("/")
for suffix in ("/v1/audio/speech", "/tts/batch"):
if api_url.endswith(suffix):
api_url = api_url[:-len(suffix)]
return api_url if api_url.endswith("/tts") else f"{api_url}/tts"
def voxcpm2_tts(text: str, voice_name: str, voice_file: str) -> Union[SubMaker, None]:
"""使用 VoxCPM-2B-Pack 的 /tts 接口进行音色设计或参考音频克隆。"""
pack_config = getattr(config, "voxcpm_2b", {}) or {}
api_url = _normalize_voxcpm2_api_url(pack_config.get("api_url", "http://127.0.0.1:7863"))
mode = str(pack_config.get("mode", "design"))
parsed_voice = parse_voxcpm2_voice(voice_name)
reference_audio = ""
if mode == "clone":
reference_audio = parsed_voice if parsed_voice and os.path.isfile(parsed_voice) else pack_config.get("reference_audio", "")
if not reference_audio or not os.path.isfile(reference_audio):
logger.error(f"VoxCPM-2B 参考音频文件不存在: {reference_audio}")
return None
data = {"text": text.strip()}
optional_fields = {
"control": pack_config.get("control"),
"prompt_text": pack_config.get("prompt_text") if mode == "clone" else None,
"cfg_value": pack_config.get("cfg_value"),
"inference_timesteps": pack_config.get("inference_timesteps"),
}
for key, value in optional_fields.items():
if value not in (None, ""):
data[key] = value
for key in ("normalize", "denoise", "output_48k", "context_aware", "streaming"):
if key in pack_config:
data[key] = str(bool(pack_config[key])).lower()
proxies = _get_configured_proxies()
for attempt in range(3):
files = {}
try:
if reference_audio:
files["reference_audio"] = open(reference_audio, "rb")
response = requests.post(api_url, data=data, files=files or None, proxies=proxies, timeout=600)
if response.status_code == 200:
result = response.json()
downloads = result.get("downloads", {}) if isinstance(result, dict) else {}
audio_url = downloads.get("wav", "") if isinstance(downloads, dict) else ""
if audio_url:
audio_response = requests.get(urljoin(api_url, audio_url), proxies=proxies, timeout=180)
if audio_response.status_code == 200:
with open(voice_file, "wb") as output:
output.write(audio_response.content)
if os.path.getsize(voice_file) > 0:
sub_maker = new_sub_maker()
duration = get_audio_duration_from_file(voice_file)
duration_ms = int(duration * 1000) if duration > 0 else max(1000, len(text) * 200)
add_subtitle_event(sub_maker, 0, duration_ms * 10000, text)
return sub_maker
logger.error(f"VoxCPM-2B API 响应中没有有效 WAV 下载地址: {result}")
else:
logger.error(f"VoxCPM-2B API 调用失败: {response.status_code} - {response.text}")
except (ValueError, requests.exceptions.RequestException) as exc:
logger.error(f"VoxCPM-2B TTS 请求失败 (尝试 {attempt + 1}/3): {exc}")
finally:
for file_obj in files.values():
file_obj.close()
if attempt < 2:
time.sleep(2)
return None
def _normalize_omnivoice_api_url(api_url: str) -> str:
api_url = (api_url or "http://127.0.0.1:7866/tts").strip()
if api_url.endswith("/tts"):

View File

@ -53,7 +53,8 @@
# - Qwen: qwen/qwen-plus, qwen/qwen-turbo
# - SiliconFlow: siliconflow/deepseek-ai/DeepSeek-R1
# - Moonshot: moonshot/moonshot-v1-8k
text_openai_model_name = "Pro/zai-org/GLM-5"
text_openai_model_name = "Pro/zai-org/GLM-5" # 高推理模型:剧情分析、文案生成、脚本匹配
text_openai_fast_model_name = "" # 高效率模型:字幕翻译、字幕校准;留空时回退到高推理模型
text_openai_api_key = "" # 填入对应 provider 的 API key
text_openai_base_url = "https://api.siliconflow.cn/v1" # 可选:自定义 API base URL界面会提示 API key 将发送到对应端点
text_openai_temperature = 1.0
@ -67,14 +68,19 @@
tavily_search_depth = "basic" # basic / advanced / fast / ultra-fast
tavily_max_results = 5
# ===== 可选Sonilo AI 配乐BGM=====
# 在 WebUI 背景音乐来源中选择 "AI 生成配乐Sonilo" 即可启用(默认关闭,不影响现有 BGM 逻辑)。
# ===== 可选Sonilo AI 配乐BGM/ AI 音效SFX=====
# 配乐:在 WebUI 背景音乐来源中选择 "AI 生成配乐Sonilo" 即可启用(默认关闭,不影响现有 BGM 逻辑)。
# 启用后会将合成完成的视频(未加 BGM上传到 Sonilo API根据画面内容与剪辑节奏生成配乐
# 生成的音乐已获授权、可商用(以条款为准)。视频时长上限 6 分钟,生成失败时自动回退到随机背景音乐。
sonilo_api_key = "" # 获取地址https://sonilo.com
# 音效:在 WebUI 音频设置中勾选 "AI 音效Sonilo" 即可启用(默认关闭)。
# 启用后会将合成完成的视频上传到 Sonilo API根据画面内容生成音效并混在现有音轨之下解说不受影响
# 生成的音效为免版税素材。视频时长上限 3 分钟,生成失败时自动跳过音效。
sonilo_api_key = "" # 获取地址https://sonilo.com配乐与音效共用
# sonilo_base_url = "https://api.sonilo.com"
# sonilo_timeout_seconds = 600 # 生成接口读超时(秒)
# sonilo_timeout_seconds = 600 # 生成接口读超时 / 音效任务等待上限(秒)
# sonilo_bgm_prompt = "" # 可选:配乐风格提示,留空则完全根据画面生成
# sonilo_sfx_prompt = "" # 可选:音效风格提示,留空则完全根据画面生成
# sonilo_sfx_volume = 0.6 # 音效混入原声之下的音量,范围 (0, 2],默认 0.6
# ===== API Keys 参考 =====
# 主流 LLM Providers API Key 获取地址:
@ -239,6 +245,35 @@
postprocess_output = true
preprocess_prompt = true
[voxcpm_05b]
# VoxCPM-0.5B-Pack 本地语音合成/参考音频克隆配置
# 启动整合包后,可填写服务根地址或完整 /tts 地址
api_url = "http://127.0.0.1:7864"
reference_audio_source = "resource"
reference_audio = ""
prompt_text = ""
cfg_value = 2.0
inference_timesteps = 10
max_length = 4096
normalize = true
denoise = false
[voxcpm_2b]
# VoxCPM-2B-Pack 本地语音合成、音色设计与参考音频克隆
api_url = "http://127.0.0.1:7863"
mode = "design"
control = ""
reference_audio_source = "resource"
reference_audio = ""
prompt_text = ""
cfg_value = 2.0
inference_timesteps = 10
normalize = true
denoise = false
output_48k = true
context_aware = true
streaming = false
[doubaotts]
# 豆包语音 TTS 配置
# 新版配置优先填写 API Key旧版 appid/token 配置仍兼容

View File

@ -1 +1 @@
0.8.6
0.8.7

View File

@ -0,0 +1,362 @@
import os
import tempfile
import unittest
from unittest import mock
import requests
from app.services import sonilo
def _response(status_code=200, json_body=None, content=b"{}"):
resp = mock.Mock()
resp.status_code = status_code
resp.content = content
if json_body is None:
resp.json.side_effect = ValueError("no json")
else:
resp.json.return_value = json_body
return resp
class SubmitSfxTaskTests(unittest.TestCase):
def setUp(self):
self._tmp_dir = tempfile.TemporaryDirectory()
self.addCleanup(self._tmp_dir.cleanup)
self.video_path = os.path.join(self._tmp_dir.name, "combined.mp4")
with open(self.video_path, "wb") as f:
f.write(b"fake video")
def test_returns_task_id(self):
with mock.patch.object(sonilo, "get_api_key", return_value="sk-test"), \
mock.patch.object(
sonilo.requests,
"post",
return_value=_response(202, {"task_id": "task-123"}),
) as post_mock:
self.assertEqual("task-123", sonilo._submit_sfx_task(self.video_path))
args, kwargs = post_mock.call_args
self.assertTrue(args[0].endswith("/v1/video-to-sfx"))
self.assertEqual("Bearer sk-test", kwargs["headers"]["Authorization"])
def test_http_error_raises(self):
with mock.patch.object(sonilo, "get_api_key", return_value="sk-test"), \
mock.patch.object(
sonilo.requests,
"post",
return_value=_response(402, content=b'{"detail": "no credits"}'),
):
with self.assertRaises(sonilo.SoniloError):
sonilo._submit_sfx_task(self.video_path)
def test_missing_task_id_raises(self):
with mock.patch.object(sonilo, "get_api_key", return_value="sk-test"), \
mock.patch.object(
sonilo.requests, "post", return_value=_response(202, {})
):
with self.assertRaises(sonilo.SoniloError):
sonilo._submit_sfx_task(self.video_path)
class PollSfxTaskTests(unittest.TestCase):
def test_returns_body_when_succeeded(self):
responses = [
_response(200, {"status": "processing"}),
_response(
200,
{"status": "succeeded", "audio": {"url": "https://cdn/x.m4a"}},
),
]
with mock.patch.object(sonilo, "get_api_key", return_value="sk-test"), \
mock.patch.object(sonilo, "_sleep"), \
mock.patch.object(sonilo.requests, "get", side_effect=responses):
body = sonilo._poll_sfx_task("task-123")
self.assertEqual("succeeded", body["status"])
def test_failed_status_raises_with_task_id(self):
response = _response(
200,
{
"status": "failed",
"error": {"code": "GENERATION_FAILED", "message": "boom"},
"refunded": True,
},
)
with mock.patch.object(sonilo, "get_api_key", return_value="sk-test"), \
mock.patch.object(sonilo, "_sleep"), \
mock.patch.object(sonilo.requests, "get", return_value=response):
with self.assertRaises(sonilo.SoniloError) as ctx:
sonilo._poll_sfx_task("task-123")
self.assertIn("task-123", str(ctx.exception))
self.assertIn("boom", str(ctx.exception))
def test_transient_error_retries_until_succeeded(self):
responses = [
requests.exceptions.ConnectionError("blip"),
_response(
200,
{"status": "succeeded", "audio": {"url": "https://cdn/x.m4a"}},
),
]
with mock.patch.object(sonilo, "get_api_key", return_value="sk-test"), \
mock.patch.object(sonilo, "_sleep"), \
mock.patch.object(sonilo.requests, "get", side_effect=responses):
body = sonilo._poll_sfx_task("task-123")
self.assertEqual("succeeded", body["status"])
def test_non_recoverable_http_error_raises(self):
response = _response(401, content=b'{"detail": "bad key"}')
with mock.patch.object(sonilo, "get_api_key", return_value="sk-test"), \
mock.patch.object(sonilo, "_sleep"), \
mock.patch.object(sonilo.requests, "get", return_value=response):
with self.assertRaises(sonilo.SoniloError):
sonilo._poll_sfx_task("task-123")
def test_timeout_raises_with_task_id(self):
response = _response(200, {"status": "processing"})
with mock.patch.object(sonilo, "get_api_key", return_value="sk-test"), \
mock.patch.object(sonilo, "_sleep"), \
mock.patch.object(sonilo, "_get_timeout_seconds", return_value=0.0), \
mock.patch.object(sonilo.requests, "get", return_value=response):
with self.assertRaises(sonilo.SoniloError) as ctx:
sonilo._poll_sfx_task("task-123")
self.assertIn("task-123", str(ctx.exception))
class DownloadSfxAudioTests(unittest.TestCase):
def test_returns_content_without_auth_headers(self):
response = _response(200, content=b"audio-bytes")
with mock.patch.object(
sonilo.requests, "get", return_value=response
) as get_mock:
self.assertEqual(
b"audio-bytes", sonilo._download_sfx_audio("https://cdn/x.m4a")
)
# 预签名 URL 自带鉴权,绝不能把 API Key 发给存储域名。
_, kwargs = get_mock.call_args
self.assertNotIn("headers", kwargs)
def test_http_error_raises(self):
with mock.patch.object(
sonilo.requests, "get", return_value=_response(403, content=b"denied")
):
with self.assertRaises(sonilo.SoniloError):
sonilo._download_sfx_audio("https://cdn/x.m4a")
def test_empty_content_raises(self):
with mock.patch.object(
sonilo.requests, "get", return_value=_response(200, content=b"")
):
with self.assertRaises(sonilo.SoniloError):
sonilo._download_sfx_audio("https://cdn/x.m4a")
class ExtractSfxAudioUrlTests(unittest.TestCase):
def test_returns_audio_url(self):
body = {"status": "succeeded", "audio": {"url": "https://cdn/x.m4a"}}
self.assertEqual(
"https://cdn/x.m4a", sonilo._extract_sfx_audio_url(body, "task-123")
)
def test_missing_audio_raises(self):
with self.assertRaises(sonilo.SoniloError):
sonilo._extract_sfx_audio_url({"status": "succeeded"}, "task-123")
class GenerateSfxTests(unittest.TestCase):
def setUp(self):
self._tmp_dir = tempfile.TemporaryDirectory()
self.addCleanup(self._tmp_dir.cleanup)
self.video_path = os.path.join(self._tmp_dir.name, "combined.mp4")
with open(self.video_path, "wb") as f:
f.write(b"fake video")
self.save_path = os.path.join(self._tmp_dir.name, "sonilo_sfx.m4a")
def test_returns_empty_without_api_key(self):
with mock.patch.object(sonilo, "get_api_key", return_value=""):
self.assertEqual("", sonilo.generate_sfx(self.video_path, self.save_path))
def test_returns_empty_when_video_missing(self):
with mock.patch.object(sonilo, "get_api_key", return_value="sk-test"):
missing = os.path.join(self._tmp_dir.name, "missing.mp4")
self.assertEqual("", sonilo.generate_sfx(missing, self.save_path))
def test_skips_upload_when_duration_exceeds_limit(self):
with mock.patch.object(sonilo, "get_api_key", return_value="sk-test"), \
mock.patch.object(sonilo, "_probe_video_duration", return_value=181.0), \
mock.patch.object(sonilo, "_request_video_to_sfx") as request_mock:
self.assertEqual("", sonilo.generate_sfx(self.video_path, self.save_path))
request_mock.assert_not_called()
def test_saves_audio_on_success(self):
with mock.patch.object(sonilo, "get_api_key", return_value="sk-test"), \
mock.patch.object(sonilo, "_probe_video_duration", return_value=60.0), \
mock.patch.object(sonilo, "_request_video_to_sfx", return_value=b"audio-bytes"):
self.assertEqual(self.save_path, sonilo.generate_sfx(self.video_path, self.save_path))
with open(self.save_path, "rb") as f:
self.assertEqual(b"audio-bytes", f.read())
def test_request_failure_degrades_to_empty(self):
with mock.patch.object(sonilo, "get_api_key", return_value="sk-test"), \
mock.patch.object(sonilo, "_probe_video_duration", return_value=60.0), \
mock.patch.object(
sonilo,
"_request_video_to_sfx",
side_effect=sonilo.SoniloError("timeout"),
):
self.assertEqual("", sonilo.generate_sfx(self.video_path, self.save_path))
self.assertFalse(os.path.exists(self.save_path))
class GetSfxVolumeTests(unittest.TestCase):
def test_default_when_unset(self):
with mock.patch.object(sonilo.config, "app", {}):
self.assertEqual(sonilo.DEFAULT_SFX_VOLUME, sonilo._get_sfx_volume())
def test_default_when_invalid(self):
with mock.patch.object(sonilo.config, "app", {"sonilo_sfx_volume": "abc"}):
self.assertEqual(sonilo.DEFAULT_SFX_VOLUME, sonilo._get_sfx_volume())
def test_default_when_non_positive(self):
with mock.patch.object(sonilo.config, "app", {"sonilo_sfx_volume": 0}):
self.assertEqual(sonilo.DEFAULT_SFX_VOLUME, sonilo._get_sfx_volume())
def test_clamped_to_upper_bound(self):
with mock.patch.object(sonilo.config, "app", {"sonilo_sfx_volume": 5}):
self.assertEqual(2.0, sonilo._get_sfx_volume())
def test_valid_value_passes_through(self):
with mock.patch.object(sonilo.config, "app", {"sonilo_sfx_volume": 0.8}):
self.assertEqual(0.8, sonilo._get_sfx_volume())
class MixSfxUnderOriginalTests(unittest.TestCase):
def _run(self, has_audio, returncode=0, run_side_effect=None):
run_mock = mock.Mock(return_value=mock.Mock(returncode=returncode, stderr=b"err"))
if run_side_effect is not None:
run_mock = mock.Mock(side_effect=run_side_effect)
with mock.patch.object(sonilo, "_get_ffmpeg_binary", return_value="ffmpeg"), \
mock.patch.object(sonilo, "_probe_has_audio_stream", return_value=has_audio), \
mock.patch.object(sonilo, "_get_sfx_volume", return_value=0.6), \
mock.patch.object(sonilo.subprocess, "run", run_mock):
result = sonilo._mix_sfx_under_original(
"/tmp/combined.mp4", "/tmp/sfx.m4a", "/tmp/merger_sfx.mp4"
)
return result, run_mock
def test_mixes_under_existing_audio_with_amix(self):
result, run_mock = self._run(has_audio=True)
self.assertEqual("/tmp/merger_sfx.mp4", result)
cmd = run_mock.call_args[0][0]
filter_complex = cmd[cmd.index("-filter_complex") + 1]
self.assertIn("volume=0.6", filter_complex)
self.assertIn("amix", filter_complex)
# 视频流直接复制,不重编码画面。
self.assertIn("copy", cmd[cmd.index("-c:v") + 1])
self.assertNotIn("-shortest", cmd)
def test_sfx_becomes_audio_track_when_video_has_no_audio(self):
result, run_mock = self._run(has_audio=False)
self.assertEqual("/tmp/merger_sfx.mp4", result)
cmd = run_mock.call_args[0][0]
filter_complex = cmd[cmd.index("-filter_complex") + 1]
self.assertNotIn("amix", filter_complex)
self.assertIn("-shortest", cmd)
def test_ffmpeg_failure_returns_empty(self):
result, _ = self._run(has_audio=True, returncode=1)
self.assertEqual("", result)
def test_ffmpeg_oserror_returns_empty(self):
result, _ = self._run(has_audio=True, run_side_effect=OSError("no ffmpeg"))
self.assertEqual("", result)
class ApplySfxTests(unittest.TestCase):
def test_success_returns_output_path(self):
with mock.patch.object(
sonilo, "generate_sfx", return_value="/tmp/merger_sfx.m4a"
) as generate_mock, mock.patch.object(
sonilo, "_mix_sfx_under_original", return_value="/tmp/merger_sfx.mp4"
) as mix_mock:
result = sonilo.apply_sfx("/tmp/combined.mp4", "/tmp/merger_sfx.mp4")
self.assertEqual("/tmp/merger_sfx.mp4", result)
generate_mock.assert_called_once_with("/tmp/combined.mp4", "/tmp/merger_sfx.m4a")
mix_mock.assert_called_once_with(
"/tmp/combined.mp4", "/tmp/merger_sfx.m4a", "/tmp/merger_sfx.mp4"
)
def test_generation_failure_skips_mixing(self):
with mock.patch.object(sonilo, "generate_sfx", return_value=""), \
mock.patch.object(sonilo, "_mix_sfx_under_original") as mix_mock:
self.assertEqual(
"", sonilo.apply_sfx("/tmp/combined.mp4", "/tmp/merger_sfx.mp4")
)
mix_mock.assert_not_called()
def test_mixing_failure_returns_empty(self):
with mock.patch.object(
sonilo, "generate_sfx", return_value="/tmp/merger_sfx.m4a"
), mock.patch.object(sonilo, "_mix_sfx_under_original", return_value=""):
self.assertEqual(
"", sonilo.apply_sfx("/tmp/combined.mp4", "/tmp/merger_sfx.mp4")
)
class ApplySoniloSfxTaskTests(unittest.TestCase):
"""任务层的音效挂载:默认关闭,失败时沿用原视频,绝不中断成片任务。"""
def _make_params(self, sfx_enabled):
params = mock.Mock()
params.sonilo_sfx_enabled = sfx_enabled
return params
def test_disabled_returns_original_path_without_calling_sonilo(self):
from app.services import task
params = self._make_params(False)
with mock.patch.object(task.sonilo, "apply_sfx") as apply_mock:
result = task._apply_sonilo_sfx("task-id", params, "/tmp/merger.mp4")
self.assertEqual("/tmp/merger.mp4", result)
apply_mock.assert_not_called()
def test_enabled_returns_sfx_video_path(self):
from app.services import task
params = self._make_params(True)
with mock.patch.object(task.utils, "task_dir", return_value="/tmp/task-id"), \
mock.patch.object(
task.sonilo, "apply_sfx", return_value="/tmp/task-id/merger_sfx.mp4"
) as apply_mock:
result = task._apply_sonilo_sfx("task-id", params, "/tmp/merger.mp4")
self.assertEqual("/tmp/task-id/merger_sfx.mp4", result)
apply_mock.assert_called_once_with(
"/tmp/merger.mp4", os.path.join("/tmp/task-id", "merger_sfx.mp4")
)
def test_failure_falls_back_to_original_path(self):
from app.services import task
params = self._make_params(True)
with mock.patch.object(task.utils, "task_dir", return_value="/tmp/task-id"), \
mock.patch.object(task.sonilo, "apply_sfx", return_value=""):
result = task._apply_sonilo_sfx("task-id", params, "/tmp/merger.mp4")
self.assertEqual("/tmp/merger.mp4", result)
if __name__ == "__main__":
unittest.main()

View File

@ -46,6 +46,8 @@ LOCAL_TTS_ENGINES = {
config.INDEXTTS_MACOS_ENGINE,
config.INDEXTTS2_ENGINE,
config.OMNIVOICE_ENGINE,
config.VOXCPM_ENGINE,
config.VOXCPM2_ENGINE,
}
@ -79,6 +81,8 @@ def get_tts_engine_options(tr=lambda key: key):
config.INDEXTTS_MACOS_ENGINE: config.INDEXTTS_MACOS_DISPLAY_NAME,
config.INDEXTTS2_ENGINE: config.INDEXTTS2_DISPLAY_NAME,
config.OMNIVOICE_ENGINE: config.OMNIVOICE_DISPLAY_NAME,
config.VOXCPM_ENGINE: config.VOXCPM_DISPLAY_NAME,
config.VOXCPM2_ENGINE: config.VOXCPM2_DISPLAY_NAME,
"edge_tts": "Edge TTS",
"qwen3_tts": tr("Tongyi Qwen3 TTS"),
"tencent_tts": tr("Tencent Cloud TTS"),
@ -157,6 +161,18 @@ def get_tts_engine_descriptions(tr=lambda key: key):
"use_case": tr("OmniVoice use case"),
"registration": None
},
config.VOXCPM_ENGINE: {
"title": config.VOXCPM_DISPLAY_NAME,
"features": tr("VoxCPM features"),
"use_case": tr("VoxCPM use case"),
"registration": None,
},
config.VOXCPM2_ENGINE: {
"title": config.VOXCPM2_DISPLAY_NAME,
"features": tr("VoxCPM2 features"),
"use_case": tr("VoxCPM2 use case"),
"registration": None,
},
"doubaotts": {
"title": tr("Doubao TTS"),
"features": tr("Doubao TTS features"),
@ -533,6 +549,9 @@ def render_audio_panel(tr):
# 背景音乐独立成框,放在音频设置下方
render_bgm_panel(tr)
# AI 音效独立成框(可选功能,默认关闭)
render_sonilo_sfx_panel(tr)
def render_bgm_panel(tr):
"""渲染背景音乐设置面板"""
@ -540,6 +559,12 @@ def render_bgm_panel(tr):
render_bgm_settings(tr)
def render_sonilo_sfx_panel(tr):
"""渲染 Sonilo AI 音效设置面板(可选功能,默认关闭)"""
with st.container(border=True):
render_sonilo_sfx_settings(tr)
def render_tts_settings(tr):
"""渲染TTS(文本转语音)设置"""
@ -603,6 +628,10 @@ def render_tts_settings(tr):
render_indextts2_tts_settings(tr)
elif selected_engine == config.OMNIVOICE_ENGINE:
render_omnivoice_tts_settings(tr)
elif selected_engine == config.VOXCPM_ENGINE:
render_voxcpm_tts_settings(tr)
elif selected_engine == config.VOXCPM2_ENGINE:
render_voxcpm2_tts_settings(tr)
elif selected_engine == "doubaotts":
render_doubaotts_settings(tr)
@ -1564,6 +1593,109 @@ def render_omnivoice_tts_settings(tr):
st.session_state["voice_pitch"] = 1.0
def render_voxcpm_tts_settings(tr):
"""渲染 VoxCPM-0.5B-Pack 设置。"""
pack_config = config.voxcpm_05b
api_url = st.text_input(
tr("API URL"),
value=pack_config.get("api_url", "http://127.0.0.1:7864"),
help=tr("VoxCPM API URL Help"),
)
use_reference = st.checkbox(
tr("VoxCPM Use Reference Audio"),
value=bool(pack_config.get("reference_audio", "")),
help=tr("VoxCPM Use Reference Audio Help"),
)
source = pack_config.get("reference_audio_source", "resource")
reference_audio = pack_config.get("reference_audio", "")
prompt_text = pack_config.get("prompt_text", "")
if use_reference:
source, reference_audio = render_indextts_reference_audio_selector(tr, pack_config, "voxcpm_05b")
prompt_text = st.text_area(
tr("VoxCPM Prompt Text"), value=prompt_text,
help=tr("VoxCPM Prompt Text Help"), height=90,
)
else:
reference_audio = ""
with st.expander(tr("Advanced Parameters"), expanded=False):
cfg_value = st.slider("CFG Value", 1.0, 3.0, float(pack_config.get("cfg_value", 2.0)), 0.1)
inference_timesteps = st.slider("Inference Timesteps", 1, 50, int(pack_config.get("inference_timesteps", 10)), 1)
max_length = st.number_input("Max Length", 128, 8192, int(pack_config.get("max_length", 4096)), 128)
normalize = st.checkbox(tr("VoxCPM Normalize"), value=bool(pack_config.get("normalize", True)))
denoise = st.checkbox(tr("VoxCPM Denoise"), value=bool(pack_config.get("denoise", False)))
with st.expander(tr("VoxCPM Usage Instructions Title"), expanded=False):
st.markdown(tr("VoxCPM Usage Instructions"))
pack_config.update({
"api_url": api_url, "reference_audio_source": source,
"reference_audio": reference_audio, "prompt_text": prompt_text,
"cfg_value": cfg_value, "inference_timesteps": inference_timesteps,
"max_length": max_length, "normalize": normalize, "denoise": denoise,
})
config.ui["voice_name"] = f"{config.VOXCPM_VOICE_PREFIX}{reference_audio or 'default'}"
st.session_state["voice_rate"] = 1.0
st.session_state["voice_pitch"] = 1.0
def render_voxcpm2_tts_settings(tr):
"""渲染 VoxCPM-2B-Pack 设置。"""
pack_config = config.voxcpm_2b
api_url = st.text_input(
tr("API URL"), value=pack_config.get("api_url", "http://127.0.0.1:7863"),
help=tr("VoxCPM2 API URL Help"),
)
mode_options = [("design", tr("VoxCPM2 Mode Design")), ("clone", tr("VoxCPM2 Mode Clone"))]
mode_values = [item[0] for item in mode_options]
saved_mode = pack_config.get("mode", "design")
if saved_mode not in mode_values:
saved_mode = "design"
mode = mode_options[st.selectbox(
tr("VoxCPM2 Generation Mode"), options=range(len(mode_options)),
index=mode_values.index(saved_mode), format_func=lambda index: mode_options[index][1],
help=tr("VoxCPM2 Generation Mode Help"),
)][0]
control = st.text_area(
tr("VoxCPM2 Voice Control"), value=pack_config.get("control", ""),
help=tr("VoxCPM2 Voice Control Help"), height=80,
)
source = pack_config.get("reference_audio_source", "resource")
reference_audio = pack_config.get("reference_audio", "")
prompt_text = pack_config.get("prompt_text", "")
if mode == "clone":
source, reference_audio = render_indextts_reference_audio_selector(tr, pack_config, "voxcpm_2b")
prompt_text = st.text_area(
tr("VoxCPM Prompt Text"), value=prompt_text,
help=tr("VoxCPM Prompt Text Help"), height=90,
)
else:
reference_audio = ""
with st.expander(tr("Advanced Parameters"), expanded=False):
cfg_value = st.slider("CFG Value", 1.0, 3.0, float(pack_config.get("cfg_value", 2.0)), 0.1, key="voxcpm2_cfg")
inference_timesteps = st.slider("Inference Timesteps", 1, 50, int(pack_config.get("inference_timesteps", 10)), 1, key="voxcpm2_steps")
normalize = st.checkbox(tr("VoxCPM Normalize"), value=bool(pack_config.get("normalize", True)), key="voxcpm2_normalize")
denoise = st.checkbox(tr("VoxCPM Denoise"), value=bool(pack_config.get("denoise", False)), key="voxcpm2_denoise")
output_48k = st.checkbox(tr("VoxCPM2 Output 48k"), value=bool(pack_config.get("output_48k", True)))
context_aware = st.checkbox(tr("VoxCPM2 Context Aware"), value=bool(pack_config.get("context_aware", True)))
streaming = st.checkbox(tr("VoxCPM2 Streaming"), value=bool(pack_config.get("streaming", False)))
with st.expander(tr("VoxCPM2 Usage Instructions Title"), expanded=False):
st.markdown(tr("VoxCPM2 Usage Instructions"))
pack_config.update({
"api_url": api_url, "mode": mode, "control": control,
"reference_audio_source": source, "reference_audio": reference_audio,
"prompt_text": prompt_text, "cfg_value": cfg_value,
"inference_timesteps": inference_timesteps, "normalize": normalize,
"denoise": denoise, "output_48k": output_48k,
"context_aware": context_aware, "streaming": streaming,
})
config.ui["voice_name"] = f"{config.VOXCPM2_VOICE_PREFIX}{reference_audio if mode == 'clone' else mode}"
st.session_state["voice_rate"] = 1.0
st.session_state["voice_pitch"] = 1.0
def render_doubaotts_settings(tr):
"""渲染豆包语音 TTS 设置"""
api_key = st.text_input(
@ -1876,6 +2008,17 @@ def render_voice_preview_new(tr, selected_engine):
voice_name = f"{config.OMNIVOICE_VOICE_PREFIX}{mode}"
voice_rate = config.omnivoice.get("speed", 1.0)
voice_pitch = 1.0
elif selected_engine == config.VOXCPM_ENGINE:
reference_audio = config.voxcpm_05b.get("reference_audio", "")
voice_name = f"{config.VOXCPM_VOICE_PREFIX}{reference_audio or 'default'}"
voice_rate = 1.0
voice_pitch = 1.0
elif selected_engine == config.VOXCPM2_ENGINE:
mode = config.voxcpm_2b.get("mode", "design")
reference_audio = config.voxcpm_2b.get("reference_audio", "")
voice_name = f"{config.VOXCPM2_VOICE_PREFIX}{reference_audio if mode == 'clone' else mode}"
voice_rate = 1.0
voice_pitch = 1.0
elif selected_engine == "doubaotts":
voice_type = config.ui.get("doubaotts_voice_type", "BV700_streaming")
voice_name = voice_type
@ -1893,6 +2036,8 @@ def render_voice_preview_new(tr, selected_engine):
config.INDEXTTS_MACOS_ENGINE,
config.INDEXTTS2_ENGINE,
config.OMNIVOICE_ENGINE,
config.VOXCPM_ENGINE,
config.VOXCPM2_ENGINE,
) else "audio/mp3"
audio_extension = ".wav" if audio_format == "audio/wav" else ".mp3"
audio_file = os.path.join(temp_dir, f"tmp-voice-{str(uuid4())}{audio_extension}")
@ -2072,6 +2217,55 @@ def render_sonilo_bgm_settings(tr):
st.warning(tr("Sonilo API Key Required"))
def render_sonilo_sfx_settings(tr):
"""渲染 Sonilo AI 音效设置(可选功能,默认关闭)"""
# 避免在本模块顶层引入 basic_settings 的重依赖链,按需导入。
from webui.components.basic_settings import update_app_config_if_changed
sfx_enabled = st.checkbox(
tr("Sonilo AI Sound Effects"),
value=bool(st.session_state.get("sonilo_sfx_enabled", False)),
help=tr("Sonilo SFX Help"),
key="sonilo_sfx_enabled_checkbox",
)
st.session_state["sonilo_sfx_enabled"] = bool(sfx_enabled)
if not sfx_enabled:
return
st.info(tr("Sonilo SFX Notice"))
sonilo_api_key = st.text_input(
tr("Sonilo API Key"),
value=config.app.get("sonilo_api_key", ""),
type="password",
help=tr("Sonilo API Key Help"),
key="sonilo_sfx_api_key_input",
)
sonilo_sfx_prompt = st.text_input(
tr("Sonilo SFX Prompt"),
value=config.app.get("sonilo_sfx_prompt", ""),
help=tr("Sonilo SFX Prompt Help"),
key="sonilo_sfx_prompt_input",
)
api_key_changed = update_app_config_if_changed(
"sonilo_api_key", str(sonilo_api_key or "").strip()
)
prompt_changed = update_app_config_if_changed(
"sonilo_sfx_prompt", str(sonilo_sfx_prompt or "").strip()
)
if api_key_changed or prompt_changed:
try:
config.save_config()
st.success(tr("Sonilo config saved"))
except Exception as e:
st.error(f"{tr('Failed to save config')}: {str(e)}")
logger.error(f"保存 Sonilo 配置失败: {str(e)}")
if not sonilo.is_enabled():
st.warning(tr("Sonilo SFX API Key Required"))
def render_bgm_settings(tr):
"""渲染背景音乐设置"""
saved_bgm_file = st.session_state.get('bgm_file', '')
@ -2207,5 +2401,6 @@ def get_audio_params():
'bgm_type': st.session_state.get('bgm_type', 'random'),
'bgm_file': st.session_state.get('bgm_file', ''),
'bgm_volume': st.session_state.get('bgm_volume', AudioVolumeDefaults.BGM_VOLUME),
'sonilo_sfx_enabled': bool(st.session_state.get('sonilo_sfx_enabled', False)),
'tts_engine': st.session_state.get('tts_engine', config.INDEXTTS_ENGINE),
}

View File

@ -9,6 +9,7 @@ from app.config.defaults import (
DEFAULT_OPENAI_COMPATIBLE_BASE_URL,
DEFAULT_OPENAI_COMPATIBLE_PROVIDER,
DEFAULT_TEXT_LLM_PROVIDER,
DEFAULT_TEXT_OPENAI_FAST_MODEL_NAME,
DEFAULT_TEXT_OPENAI_MODEL_NAME,
DEFAULT_VISION_LLM_PROVIDER,
DEFAULT_VISION_OPENAI_MODEL_NAME,
@ -876,6 +877,10 @@ def render_text_llm_settings(tr):
# 获取已保存的配置
full_text_model_name = config.app.get("text_openai_model_name") or DEFAULT_TEXT_OPENAI_MODEL_NAME
full_fast_model_name = (
config.app.get("text_openai_fast_model_name")
or DEFAULT_TEXT_OPENAI_FAST_MODEL_NAME
)
text_api_key = config.app.get("text_openai_api_key", "")
text_base_url = config.app.get("text_openai_base_url", DEFAULT_OPENAI_COMPATIBLE_BASE_URL)
@ -885,10 +890,14 @@ def render_text_llm_settings(tr):
DEFAULT_TEXT_OPENAI_MODEL_NAME,
provider=DEFAULT_TEXT_LLM_PROVIDER,
)
current_fast_model = normalize_openai_compatible_model_id(
full_fast_model_name,
provider=DEFAULT_TEXT_LLM_PROVIDER,
)
selected_provider = DEFAULT_TEXT_LLM_PROVIDER
# 渲染配置输入框
col1, col2 = st.columns([1, 2])
col1, col2, col3 = st.columns([1, 2, 2])
with col1:
render_openai_compatible_protocol_field(
tr,
@ -897,11 +906,13 @@ def render_text_llm_settings(tr):
)
with col2:
model_name_input = st.text_input(
tr("Text Model Name"),
reasoning_model_name_input = st.text_input(
tr("High Reasoning Model Name"),
value=current_model,
help=(
tr("Model Name Input Help")
tr("High Reasoning Model Help")
+ "\n\n"
+ tr("Model Name Input Help")
+ "\n\n"
+ "• Pro/zai-org/GLM-5\n"
+ "• deepseek/deepseek-chat\n"
@ -912,8 +923,24 @@ def render_text_llm_settings(tr):
key="text_model_input"
)
with col3:
fast_model_name_input = st.text_input(
tr("High Efficiency Model Name"),
value=current_fast_model,
help=(
tr("High Efficiency Model Help")
+ "\n\n"
+ "• Qwen/Qwen3.5-32B\n"
+ "• gpt-4o-mini\n"
+ "• gemini-2.5-flash\n"
+ "• deepseek/deepseek-chat"
),
key="text_fast_model_input",
)
# 组合完整的模型名称
st_text_model_name = normalize_openai_compatible_model_name(model_name_input)
st_text_model_name = normalize_openai_compatible_model_name(reasoning_model_name_input)
st_text_fast_model_name = normalize_openai_compatible_model_name(fast_model_name_input)
st_text_api_key = st.text_input(
tr("Text API Key"),
@ -952,7 +979,7 @@ def render_text_llm_settings(tr):
test_errors = []
if not st_text_api_key:
test_errors.append(tr("Please enter API key"))
if not model_name_input:
if not reasoning_model_name_input:
test_errors.append(tr("Please enter model name"))
if test_errors:
@ -961,17 +988,25 @@ def render_text_llm_settings(tr):
else:
with st.spinner(tr("Testing connection...")):
try:
success, message = test_openai_compatible_text_model(
api_key=st_text_api_key,
base_url=st_text_base_url,
model_name=st_text_model_name,
tr=tr
)
if success:
st.success(message)
else:
st.error(message)
test_targets = [
(tr("High Reasoning Model Name"), st_text_model_name),
]
if st_text_fast_model_name:
test_targets.append((
tr("High Efficiency Model Name"),
st_text_fast_model_name,
))
for label, target_model in test_targets:
success, message = test_openai_compatible_text_model(
api_key=st_text_api_key,
base_url=st_text_base_url,
model_name=target_model,
tr=tr,
)
if success:
st.success(f"{label}: {message}")
else:
st.error(f"{label}: {message}")
except Exception as e:
st.error(f"{tr('Connection test error')}: {str(e)}")
logger.error(f"OpenAI 兼容 文案生成模型连接测试失败: {str(e)}")
@ -992,6 +1027,25 @@ def render_text_llm_settings(tr):
else:
text_validation_errors.append(error_msg)
if st_text_fast_model_name:
is_valid, error_msg = validate_openai_compatible_model_name(
st_text_fast_model_name,
"高效率文案生成",
)
if is_valid:
text_config_changed |= update_app_config_if_changed(
"text_openai_fast_model_name",
st_text_fast_model_name,
)
st.session_state["text_openai_fast_model_name"] = st_text_fast_model_name
else:
text_validation_errors.append(error_msg)
else:
text_config_changed |= update_app_config_if_changed(
"text_openai_fast_model_name",
"",
)
# 验证 API 密钥
if st_text_api_key:
is_valid, error_msg = validate_api_key(st_text_api_key, "文案生成")
@ -1027,7 +1081,7 @@ def render_text_llm_settings(tr):
config.save_config()
# 清除缓存,确保下次使用新配置
UnifiedLLMService.clear_cache()
if st_text_api_key or st_text_base_url or st_text_model_name:
if st_text_api_key or st_text_base_url or st_text_model_name or st_text_fast_model_name:
st.success(tr("Text model config saved"))
except Exception as e:
st.error(f"{tr('Failed to save config')}: {str(e)}")

View File

@ -97,6 +97,9 @@ FILM_TV_TYPE_VALUES = {
"horror_thriller": "恐怖/惊悚",
}
SHORT_DRAMA_ORIGINAL_SOUND_RATIO_OPTIONS = list(range(0, 100, 10))
DEFAULT_NARRATION_WORD_COUNT = 500
MIN_NARRATION_WORD_COUNT = 100
MAX_NARRATION_WORD_COUNT = 5000
SUMMARY_MODE_CONFIGS = {
MODE_FILM_SUMMARY: {
"mode_label_key": "Film TV Narration",
@ -1196,6 +1199,8 @@ def render_fun_asr_transcription(tr):
)
backend = backend_options[backend_label]
st.markdown(tr("Subtitle transcription package downloads"))
if backend == "upload":
render_subtitle_upload(tr)
elif backend == "local":
@ -1572,6 +1577,7 @@ def render_script_buttons(tr, params):
type_option_key = _summary_state_key(summary_config, "type_option")
custom_type_key = _summary_state_key(summary_config, "custom_type")
original_sound_ratio_key = _summary_state_key(summary_config, "original_sound_ratio")
narration_word_count_key = _summary_state_key(summary_config, "narration_word_count")
language_option_key = _summary_state_key(summary_config, "narration_language_option")
custom_language_key = _summary_state_key(summary_config, "custom_narration_language")
narration_copy_key = _summary_state_key(summary_config, "narration_copy")
@ -1590,7 +1596,7 @@ def render_script_buttons(tr, params):
config_col_widths = [1.15]
if show_custom_type:
config_col_widths.append(1.15)
config_col_widths.extend([0.9, 1.15])
config_col_widths.extend([0.9, 0.9, 1.15])
if show_custom_language:
config_col_widths.append(1.15)
@ -1621,6 +1627,16 @@ def render_script_buttons(tr, params):
key=original_sound_ratio_key,
)
config_col_index += 1
with config_cols[config_col_index]:
st.number_input(
tr("文案字数"),
min_value=MIN_NARRATION_WORD_COUNT,
max_value=MAX_NARRATION_WORD_COUNT,
value=DEFAULT_NARRATION_WORD_COUNT,
step=50,
key=narration_word_count_key,
)
config_col_index += 1
with config_cols[config_col_index]:
st.selectbox(
tr("解说语言"),
@ -1661,6 +1677,7 @@ def render_script_buttons(tr, params):
type_option_key = _summary_state_key(summary_config, "type_option")
custom_type_key = _summary_state_key(summary_config, "custom_type")
original_sound_ratio_key = _summary_state_key(summary_config, "original_sound_ratio")
narration_word_count_key = _summary_state_key(summary_config, "narration_word_count")
language_option_key = _summary_state_key(summary_config, "narration_language_option")
custom_language_key = _summary_state_key(summary_config, "custom_narration_language")
narration_copy_key = _summary_state_key(summary_config, "narration_copy")
@ -1672,6 +1689,9 @@ def render_script_buttons(tr, params):
narration_language = _resolve_summary_narration_language(summary_config)
drama_genre = _resolve_summary_type(summary_config)
original_sound_ratio = int(st.session_state.get(original_sound_ratio_key, 30))
narration_word_count = int(
st.session_state.get(narration_word_count_key, DEFAULT_NARRATION_WORD_COUNT)
)
if (
st.session_state.get(type_option_key) == "custom"
and not str(st.session_state.get(custom_type_key, '') or '').strip()
@ -1718,6 +1738,7 @@ def render_script_buttons(tr, params):
video_paths=_selected_video_paths(),
narration_language=narration_language,
drama_genre=drama_genre,
narration_word_count=narration_word_count,
prompt_category=summary_config["prompt_category"],
search_keywords=summary_config["search_keywords"],
empty_title_message_key=summary_config["empty_title_message_key"],

View File

@ -58,11 +58,17 @@
"Sonilo AI Background Music": "AI-Generated Background Music (Sonilo)",
"Sonilo BGM Notice": "When enabled, the assembled video (without background music) is uploaded to the Sonilo API to generate background music that follows the visuals and editing pace; the narration keeps its existing volume settings and stays clearly audible. Generated music is licensed for commercial use (terms apply). Videos longer than 6 minutes are not supported; if generation fails, the task falls back to random background music.",
"Sonilo API Key": "Sonilo API Key",
"Sonilo API Key Help": "Get an API key at https://sonilo.com. Only used when Sonilo background music is selected.",
"Sonilo API Key Help": "Get an API key at https://sonilo.com. Only used when Sonilo background music or sound effects are enabled.",
"Sonilo API Key Required": "Please enter a Sonilo API Key first, otherwise the task will fall back to random background music.",
"Sonilo BGM Prompt": "Music Style Hint (Optional)",
"Sonilo BGM Prompt Help": "Optional: describe the desired music style, e.g. \"calm piano\" or \"tense suspense\". Leave empty to generate purely from the visuals.",
"Sonilo config saved": "Sonilo configuration saved",
"Sonilo AI Sound Effects": "AI Sound Effects (Sonilo)",
"Sonilo SFX Help": "Automatically generate sound effects that match the visuals of the assembled video (optional, disabled by default).",
"Sonilo SFX Notice": "When enabled, the assembled video is uploaded to the Sonilo API to generate sound effects based on the visuals; the effects are mixed underneath the existing audio track, and the narration keeps its existing volume settings and stays clearly audible. Generated sound effects are royalty-free. Videos longer than 3 minutes are not supported; if generation fails, the sound-effects step is skipped and the video is produced as usual.",
"Sonilo SFX Prompt": "Sound Effects Hint (Optional)",
"Sonilo SFX Prompt Help": "Optional: describe the desired sound effects, e.g. \"rain with distant thunder\" or \"metal clanking\". Leave empty to generate purely from the visuals.",
"Sonilo SFX API Key Required": "Please enter a Sonilo API Key first, otherwise the sound-effects step will be skipped.",
"Upload Background Music": "Upload Background Music",
"Background Music Path Help": "Choose the background music used for video synthesis.",
"No Background Music Resources Found": "No background music resources found. Please upload a background music file.",
@ -205,6 +211,10 @@
"Text API Key": "Text API Key",
"Text Base URL": "Text Base URL",
"Text Model Name": "Text Model Name",
"High Reasoning Model Name": "High-Reasoning Model Name",
"High Efficiency Model Name": "High-Efficiency Model Name",
"High Reasoning Model Help": "Used for plot analysis, copy generation, and script generation and matching.",
"High Efficiency Model Help": "Used for subtitle translation and calibration; falls back to the high-reasoning model when empty.",
"Top P": "Top P",
"Top K": "Top K",
"Max Output Tokens": "Max Output Tokens",
@ -341,6 +351,7 @@
"影视类型": "Film/TV Type",
"自定义影视类型": "Custom Film/TV Type",
"原片占比": "Original Footage Ratio",
"文案字数": "Copy Length",
"例如:豪门虐恋": "For example: billionaire angst romance",
"例如:悬疑犯罪": "For example: suspense crime",
"请输入自定义短剧类型": "Please enter a custom short drama type",
@ -390,6 +401,10 @@
"IndexTTS2 use case": "Best for fixed voices, emotional narration, and local speech synthesis workflows that need finer sampling controls. Start the IndexTTS-2 MLX Pack service before use. Deployment packages:\n\n- **macOS**: [Download](https://cutagent.online/resources/indextts2-full-macos)\n- **Windows**: Coming soon",
"OmniVoice features": "A locally or privately deployed OmniVoice-Pack multilingual TTS engine with automatic voice generation, voice design, and reference-audio cloning.",
"OmniVoice use case": "Best for local controllable multilingual narration, voice design, or reference-audio cloning. Start the OmniVoice-Pack API service before use. Deployment packages:\n\n- **macOS**: [Download](https://cutagent.online/resources/omnivoice-macos)\n- **Windows**: [Download](https://cutagent.online/resources/omnivoice-windows)",
"VoxCPM features": "A locally deployed VoxCPM-0.5B speech engine supporting its default voice and zero-shot cloning from reference audio.",
"VoxCPM use case": "Best for private local Chinese narration and reusable voices on Apple Silicon Macs. Start VoxCPM-0.5B-Pack before use; [download the deployment package](https://cutagent.online/resources/voxcpm-05b-macos).",
"VoxCPM2 features": "A locally deployed high-quality VoxCPM-2B engine with voice-description design, reference-audio cloning, 48k output, and context awareness.",
"VoxCPM2 use case": "Best for higher-quality local narration, describable voices, style control, and cloned voices on Apple Silicon Macs. Start VoxCPM-2B-Pack before use; [download the deployment package](https://cutagent.online/resources/voxcpm-2b-macos).",
"Doubao TTS features": "Volcengine Doubao speech synthesis with multiple voices and emotions, plus fast access in mainland China.",
"Local Deployment": "Local Deployment",
"Cloud Service": "Cloud Service",
@ -482,6 +497,7 @@
"Characters": "characters",
"Ali Bailian Fun-ASR Subtitle Transcription": "Subtitle Processing",
"Subtitle Processing Method": "Subtitle Processing Method",
"Subtitle transcription package downloads": "**Subtitle transcription package downloads**\n\n- **Fun-ASR-Nano**: [macOS](https://cutagent.online/resources/fun-asr-nano-macos) · [Windows](https://cutagent.online/resources/fun-asr-nano-windows)\n- **FunASR v1.1.0**: [macOS](https://cutagent.online/resources/funasr-v110-macos) · [Windows](https://cutagent.online/resources/funasr-v110-windows)\n- **FireRedASR2-AED**: [macOS](https://cutagent.online/resources/fireredasr2-aed-macos) · [Windows](https://cutagent.online/resources/fireredasr2-aed-windows)",
"Fun-ASR Backend": "Fun-ASR Backend",
"Local FunASR-Pack API": "FunASR (Local)",
"Local FireRedASR API": "FireRedASR2 (Local)",
@ -577,6 +593,27 @@
"IndexTTS macOS API URL Help": "IndexTTS-MLX-1.5-Pack service URL. Enter the service root or the full /v1/audio/speech/upload endpoint.",
"IndexTTS2 API URL Help": "IndexTTS-2 MLX Pack service URL. Enter the service root or the full /v1/audio/speech/upload endpoint.",
"OmniVoice API URL Help": "OmniVoice-Pack API service URL. You can enter the service root or the full /tts endpoint.",
"VoxCPM API URL Help": "VoxCPM-0.5B-Pack service URL. Enter the service root, /tts, or /v1/audio/speech endpoint.",
"VoxCPM Use Reference Audio": "Clone from Reference Audio",
"VoxCPM Use Reference Audio Help": "Uploads prompt_audio through /tts when enabled; otherwise uses the model's default voice.",
"VoxCPM Prompt Text": "Reference Audio Text",
"VoxCPM Prompt Text Help": "Optional transcript of the speech in the reference audio.",
"VoxCPM Normalize": "Normalize Text",
"VoxCPM Denoise": "Denoise Reference Audio",
"VoxCPM Usage Instructions Title": "VoxCPM-0.5B Usage Instructions",
"VoxCPM Usage Instructions": "1. Start VoxCPM-0.5B-Pack (double-click start.command on macOS).\n2. The default service URL is http://127.0.0.1:7864.\n3. Use the default voice without reference audio, or enable reference audio to clone a voice.\n4. The first request may take longer while the model loads.",
"VoxCPM2 API URL Help": "VoxCPM-2B-Pack service URL. Enter the service root, /tts, /tts/batch, or /v1/audio/speech endpoint.",
"VoxCPM2 Generation Mode": "Generation Mode",
"VoxCPM2 Generation Mode Help": "Voice design generates a voice from a text description; cloning uploads reference_audio.",
"VoxCPM2 Mode Design": "Voice Design",
"VoxCPM2 Mode Clone": "Reference Audio Clone",
"VoxCPM2 Voice Control": "Voice Description / Style Control",
"VoxCPM2 Voice Control Help": "Describe voice, emotion, pace, and expression, for example: warm, natural, slightly fast young female voice.",
"VoxCPM2 Output 48k": "Output 48kHz Audio",
"VoxCPM2 Context Aware": "Enable Context Awareness",
"VoxCPM2 Streaming": "Enable Streaming Inference",
"VoxCPM2 Usage Instructions Title": "VoxCPM-2B Usage Instructions",
"VoxCPM2 Usage Instructions": "1. Start VoxCPM-2B-Pack (double-click start.command on macOS).\n2. The default service URL is http://127.0.0.1:7863.\n3. Describe the desired voice in design mode, or choose reference audio in clone mode.\n4. Initial model loading and 2B generation may take longer.",
"OmniVoice Language Code": "Synthesis Language",
"OmniVoice Language Code Help": "The language parameter sent to OmniVoice-Pack, such as zh or en.",
"OmniVoice Generation Mode": "Generation Mode",

View File

@ -46,11 +46,17 @@
"Sonilo AI Background Music": "AI 生成配乐Sonilo",
"Sonilo BGM Notice": "启用后,合成完成的视频(未加背景音乐)将上传至 Sonilo API根据画面内容与剪辑节奏生成配乐解说配音仍按现有音量设置保持清晰。生成的音乐已获授权、可商用以条款为准。视频时长上限 6 分钟;生成失败时自动回退到随机背景音乐,不影响成片。",
"Sonilo API Key": "Sonilo API Key",
"Sonilo API Key Help": "获取地址https://sonilo.com仅在选择 Sonilo 配乐时使用",
"Sonilo API Key Help": "获取地址https://sonilo.com仅在启用 Sonilo 配乐或音效时使用",
"Sonilo API Key Required": "请先填写 Sonilo API Key否则生成时将回退到随机背景音乐",
"Sonilo BGM Prompt": "配乐风格提示(可选)",
"Sonilo BGM Prompt Help": "可选:描述期望的配乐风格,例如“舒缓钢琴”“紧张悬疑”,留空则完全根据画面生成",
"Sonilo config saved": "Sonilo 配置已保存",
"Sonilo AI Sound Effects": "AI 音效Sonilo",
"Sonilo SFX Help": "为成片自动生成贴合画面的音效(可选功能,默认关闭)",
"Sonilo SFX Notice": "启用后,合成完成的视频将上传至 Sonilo API根据画面内容生成音效并混在现有音轨之下解说配音仍按现有音量设置保持清晰。生成的音效为免版税素材。视频时长上限 3 分钟;生成失败时自动跳过音效,不影响成片。",
"Sonilo SFX Prompt": "音效风格提示(可选)",
"Sonilo SFX Prompt Help": "可选:描述期望的音效,例如“雨声和远处雷声”“金属碰撞”,留空则完全根据画面生成",
"Sonilo SFX API Key Required": "请先填写 Sonilo API Key否则生成时将跳过音效",
"Upload Background Music": "上传背景音乐",
"Background Music Path Help": "选择用于视频合成的背景音乐",
"No Background Music Resources Found": "未找到资源目录中的背景音乐,请上传背景音乐文件",
@ -194,6 +200,10 @@
"Text API Key": "文案生成 API 密钥",
"Text Base URL": "文案生成接口地址",
"Text Model Name": "文案生成模型名称",
"High Reasoning Model Name": "高推理模型名称",
"High Efficiency Model Name": "高效率模型名称",
"High Reasoning Model Help": "用于剧情分析、文案生成、脚本生成与匹配等复杂任务。",
"High Efficiency Model Help": "用于字幕翻译、字幕校准等批量任务;留空时自动使用高推理模型。",
"Top P": "Top P",
"Top K": "Top K",
"Max Output Tokens": "最大输出 Token",
@ -328,6 +338,10 @@
"IndexTTS2 use case": "适合需要固定音色、情绪化旁白或更细致采样控制的本地语音合成场景。使用前请先启动 IndexTTS-2 MLX Pack 服务;部署包下载:\n\n- **macOS**[下载地址](https://cutagent.online/resources/indextts2-full-macos)\n- **Windows**:待更新",
"OmniVoice features": "本地/私有部署的 OmniVoice-Pack 多语种语音合成引擎,支持自动音色、指令音色和参考音频克隆。",
"OmniVoice use case": "适合需要本地可控、多语言旁白、音色设计或参考音频克隆的场景。使用前请先启动 OmniVoice-Pack API 服务;部署包下载:\n\n- **macOS**[下载地址](https://cutagent.online/resources/omnivoice-macos)\n- **Windows**[下载地址](https://cutagent.online/resources/omnivoice-windows)",
"VoxCPM features": "本地部署的 VoxCPM-0.5B 语音合成引擎,支持默认音色和参考音频零样本音色克隆。",
"VoxCPM use case": "适合 Apple Silicon Mac 上的本地中文旁白、固定音色与隐私敏感型语音合成。使用前请启动 VoxCPM-0.5B-Pack[下载部署包](https://cutagent.online/resources/voxcpm-05b-macos)。",
"VoxCPM2 features": "本地部署的 VoxCPM-2B 高质量语音引擎支持音色描述设计、参考音频克隆、48k 输出和上下文感知。",
"VoxCPM2 use case": "适合 Apple Silicon Mac 上需要更高质量、可描述音色、情绪风格控制或固定克隆音色的本地旁白。使用前请启动 VoxCPM-2B-Pack[下载部署包](https://cutagent.online/resources/voxcpm-2b-macos)。",
"Doubao TTS features": "火山引擎豆包语音合成,支持多种音色和情感,国内访问速度快",
"Local Deployment": "本地部署",
"Cloud Service": "云端服务",
@ -421,6 +435,7 @@
"Characters": "字符",
"Ali Bailian Fun-ASR Subtitle Transcription": "字幕处理",
"Subtitle Processing Method": "字幕处理方式",
"Subtitle transcription package downloads": "**字幕转录整合包下载**\n\n- **Fun-ASR-Nano**[macOS](https://cutagent.online/resources/fun-asr-nano-macos) · [Windows](https://cutagent.online/resources/fun-asr-nano-windows)\n- **FunASR v1.1.0**[macOS](https://cutagent.online/resources/funasr-v110-macos) · [Windows](https://cutagent.online/resources/funasr-v110-windows)\n- **FireRedASR2-AED**[macOS](https://cutagent.online/resources/fireredasr2-aed-macos) · [Windows](https://cutagent.online/resources/fireredasr2-aed-windows)",
"Fun-ASR Backend": "Fun-ASR 后端",
"Local FunASR-Pack API": "FunASR(本地部署)",
"Local FireRedASR API": "FireRedASR2(本地部署)",
@ -516,6 +531,27 @@
"IndexTTS macOS API URL Help": "IndexTTS-MLX-1.5-Pack 服务地址,可填写服务根地址或完整 /v1/audio/speech/upload 地址",
"IndexTTS2 API URL Help": "IndexTTS-2 MLX Pack 服务地址,可填写服务根地址或完整 /v1/audio/speech/upload 地址",
"OmniVoice API URL Help": "OmniVoice-Pack API 服务地址,可填写服务根地址或完整 /tts 地址",
"VoxCPM API URL Help": "VoxCPM-0.5B-Pack 服务地址,可填写服务根地址、/tts 或 /v1/audio/speech 地址。",
"VoxCPM Use Reference Audio": "使用参考音频克隆音色",
"VoxCPM Use Reference Audio Help": "启用后通过 /tts 上传 prompt_audio关闭时使用模型默认音色。",
"VoxCPM Prompt Text": "参考音频文本",
"VoxCPM Prompt Text Help": "可选,填写参考音频中实际朗读的文字。",
"VoxCPM Normalize": "文本规范化",
"VoxCPM Denoise": "参考音频降噪",
"VoxCPM Usage Instructions Title": "VoxCPM-0.5B 使用说明",
"VoxCPM Usage Instructions": "1. 启动 VoxCPM-0.5B-PackmacOS 双击 start.command。\n2. 默认服务地址为 http://127.0.0.1:7864。\n3. 不选择参考音频时使用默认音色;启用参考音频后可克隆音色。\n4. 首次合成需要加载模型,可能耗时较长。",
"VoxCPM2 API URL Help": "VoxCPM-2B-Pack 服务地址,可填写服务根地址、/tts、/tts/batch 或 /v1/audio/speech 地址。",
"VoxCPM2 Generation Mode": "生成模式",
"VoxCPM2 Generation Mode Help": "音色设计使用文字描述生成声音;参考音频克隆会上传 reference_audio。",
"VoxCPM2 Mode Design": "音色设计",
"VoxCPM2 Mode Clone": "参考音频克隆",
"VoxCPM2 Voice Control": "音色描述 / 风格控制",
"VoxCPM2 Voice Control Help": "描述音色、情绪、语速和表达,例如:温暖、自然、稍快的年轻女声。",
"VoxCPM2 Output 48k": "输出 48kHz 音频",
"VoxCPM2 Context Aware": "启用上下文感知",
"VoxCPM2 Streaming": "启用流式推理",
"VoxCPM2 Usage Instructions Title": "VoxCPM-2B 使用说明",
"VoxCPM2 Usage Instructions": "1. 启动 VoxCPM-2B-PackmacOS 双击 start.command。\n2. 默认服务地址为 http://127.0.0.1:7863。\n3. 音色设计模式填写声音与风格描述;克隆模式选择参考音频,可补充其文本。\n4. 2B 模型首次加载和生成可能耗时较长。",
"OmniVoice Language Code": "合成语言",
"OmniVoice Language Code Help": "传给 OmniVoice-Pack 的 language 参数,例如 zh、en。",
"OmniVoice Generation Mode": "生成模式",
@ -677,6 +713,7 @@
"影视类型": "影视类型",
"自定义影视类型": "自定义影视类型",
"原片占比": "原片占比",
"文案字数": "文案字数",
"例如:豪门虐恋": "例如:豪门虐恋",
"例如:悬疑犯罪": "例如:悬疑犯罪",
"请输入自定义短剧类型": "请输入自定义短剧类型",

View File

@ -363,6 +363,7 @@ def generate_short_drama_narration_copy(
search_keywords: str = SHORT_DRAMA_SEARCH_KEYWORDS,
empty_title_message_key: str = "Please enter short drama name before web search",
web_search_context_description: str = "短剧名称、人物关系、剧情背景和公开剧情梗概",
narration_word_count: int = 500,
):
"""生成可由用户审核修改的短剧解说正文,不绑定时间戳。"""
subtitle_paths = _normalize_paths(subtitle_path)
@ -422,6 +423,7 @@ def generate_short_drama_narration_copy(
temperature=temperature,
narration_language=narration_language,
drama_genre=drama_genre,
narration_word_count=narration_word_count,
)
except Exception as e:
logger.warning(f"使用新LLM服务生成文案失败回退到旧实现: {str(e)}")
@ -436,6 +438,7 @@ def generate_short_drama_narration_copy(
provider=text_provider,
narration_language=narration_language,
drama_genre=drama_genre,
narration_word_count=narration_word_count,
prompt_category=prompt_category,
)