Compare commits

..

51 Commits
v0.8.2 ... 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
viccy
c09566ae16 更新版本号 2026-07-16 11:25:57 +08:00
viccy
445feb14ae Merge remote-tracking branch 'origin/main' 2026-07-16 11:24:39 +08:00
viccy
d0216fcb04
Merge pull request #270 from cindyxu1030/feat/sonilo-bgm
feat(bgm): 新增 Sonilo AI 配乐模式(可选,默认关闭)
2026-07-16 10:26:42 +08:00
Cindy Xu
d29874a163 feat(bgm): 新增 Sonilo AI 配乐模式(可选,默认关闭)
背景音乐来源新增 "AI 生成配乐(Sonilo)":将合成完成的视频(未加 BGM)
上传到 Sonilo API,根据画面内容与剪辑节奏生成配乐,音频文件交还给现有
合成流程混音,解说配音音量策略保持不变。

- 独立模块 app/services/sonilo.py,仅在配置 sonilo_api_key 后可用
- 上传前 ffprobe 本地校验时长(接口上限 6 分钟),避免白传计费
- 任何失败只记录日志并回退到随机背景音乐,绝不中断成片任务
- WebUI 选项处提示:视频将上传至 Sonilo API,生成的音乐已获授权、
  可商用(以条款为准)
- 现有 resource/upload/none 的 BGM 逻辑不做任何改动

Closes #269
2026-07-13 21:00:03 -07:00
viccy
b56222725d feat: add IndexTTS 1.5 macOS engine 2026-07-13 06:19:56 +08:00
viccy
f80ae97679 docs: add OmniVoice download links 2026-07-13 05:23:31 +08:00
viccy
79c89e64a3 fix: format IndexTTS download links 2026-07-13 05:15:27 +08:00
viccy
8b3812fbea docs: update IndexTTS download links 2026-07-13 04:59:12 +08:00
viccy
39eae0d511 fix: adapt IndexTTS-2 MLX Pack API 2026-07-13 04:41:09 +08:00
viccy
cd20aebacf 更新版本号 2026-07-13 03:48:31 +08:00
viccy
990fb36e98 fix(webui, tests): 修复硬编码路径与组件默认值问题并新增测试
替换音频设置模块的硬编码本地路径为资源目录工具函数,提升跨平台兼容性
修正字幕预览方向选择组件的默认值处理逻辑,移除冗余的默认参数
新增Streamlit组件会话状态合规性测试,确保带key的组件不会同时声明default参数
更新.gitignore以允许提交新增的测试文件
2026-07-13 03:48:05 +08:00
viccy
2fbd3d0c25 更新文档 2026-07-13 02:41:24 +08:00
viccy
4a5b699b11 Fix reference audio source pill state 2026-07-13 02:33:06 +08:00
viccy
f7be8aafb3
更新文档 Speclip 暂时属于内测阶段 2026-07-05 02:13:42 +08:00
viccy
0733bb9528
Merge pull request #265 from leoz9/codex/add-clear-cache-button
Add system cache clearing action
2026-07-02 16:07:42 +08:00
viccy
f0753cc6e8
Merge pull request #266 from leoz9/codex/parse-short-script-json
Use tolerant JSON parsing for short script output
2026-07-02 16:03:52 +08:00
viccy
ec1a1a4c52
Merge pull request #263 from leoz9/codex/normalize-pills-state
Normalize audio source pill state
2026-07-02 15:46:35 +08:00
zhanglei
2d0adcfdec Use tolerant JSON parsing for short scripts 2026-07-02 15:46:20 +08:00
viccy
8944db7620
Merge pull request #262 from leoz9/codex/fix-subtitle-preview-orientation
Fix subtitle preview orientation state
2026-07-02 15:46:07 +08:00
viccy
0407873eff
Merge pull request #261 from leoz9/codex/add-uv-support
Add uv-based dependency management
2026-07-02 15:45:39 +08:00
zhanglei
4666190181 Add system cache clearing action 2026-07-02 15:43:34 +08:00
zhanglei
207900a59a Normalize audio pills source state 2026-07-02 14:34:54 +08:00
zhanglei
3eaf61c3f8 Fix subtitle preview orientation state 2026-07-02 14:33:15 +08:00
zhanglei
fd43bf7c0b Add uv-based dependency management 2026-07-02 14:14:55 +08:00
viccy
40b91d25b7 chore: remove unused docs assets 2026-07-02 12:13:24 +08:00
viccy
a6b029a613 chore: switch license to MIT 2026-07-02 12:03:42 +08:00
viccy
c288a76ff8
Merge pull request #255 from hamizan-azman/codex/validate-openai-base-url
Validate OpenAI-compatible base URLs
2026-07-02 11:58:37 +08:00
viccy
0774ac5385 Warn for custom OpenAI base URLs 2026-07-02 11:57:31 +08:00
viccy
23591defb1
Merge pull request #260 from linyqh/codex/doubao-tts-v084
feat: support Doubao TTS API key auth
2026-07-02 11:41:48 +08:00
viccy
1b7bd79654 feat: support Doubao TTS API key auth 2026-07-02 11:35:23 +08:00
viccy
d02c848977
Merge pull request #259 from LauraGPT/codex/funasr-openai-transcriptions
Support FunASR OpenAI-compatible transcription endpoint
2026-06-30 10:15:45 +08:00
zhifu gao
b15f5807c1 Support FunASR OpenAI transcription endpoint 2026-06-30 06:15:18 +08:00
viccy
18c9ff81d2
Merge pull request #258 from mohit-twelvelabs/feat/twelvelabs-integration
feat(llm): 新增 TwelveLabs Pegasus 视频理解视觉提供商(可选) / add optional TwelveLabs Pegasus vision provider
2026-06-27 17:26:18 +08:00
mohit
056521a743 feat(llm): 新增 TwelveLabs Pegasus 视频理解视觉提供商(可选)
将每批关键帧拼接为短视频片段并交由 Pegasus 原生理解,更擅长把握镜头内
动作与时序,从而生成更贴合画面的解说。仅当 vision_llm_provider 设为
twelvelabs 时启用,默认行为不变;包含无网络单测与凭据门控的 live 测试。
2026-06-27 00:29:27 -07:00
viccy
d017f3ac75
Merge pull request #256 from kiritoxjf/main
腾讯云TTS音色列表更新
2026-06-22 13:43:02 +08:00
Kirito
acd27aedc7
腾讯云TTS音色列表更新 2026-06-21 21:10:56 +08:00
Hamizan Azman
5f7eed9f85 Validate OpenAI-compatible base URLs 2026-06-19 15:08:17 +08:00
viccy
fede336592 更新版本号 2026-06-15 01:33:56 +08:00
viccy
ed4a5d07e5 feat(short_drama_editing): 添加强短剧混剪剪辑脚本生成完整功能
- 新增短剧混剪脚本生成专用提示词类并完成注册
- 优化merge_script工具函数,支持多视频路径输入、自动填充视频信息
- 扩展SDP处理流水线,新增直接基于剧情分析和字幕生成剪辑脚本的逻辑
- 更新WebUI相关组件与工具函数,适配新的短剧混剪脚本生成流程
- 添加字幕时间戳校验与路径规范化工具,确保生成脚本合法性
2026-06-15 01:16:30 +08:00
viccy
8b1fcbafa5 chore(app/utils): 移除两个废弃的Gemini视觉分析工具文件 2026-06-14 00:28:33 +08:00
viccy
9f28fcfa98 feat: 添加LLM驱动的字幕翻译功能
新增配置项以配置字幕翻译批次大小和最大并发数
补充中、英文国际化文案支持翻译相关界面
实现核心字幕翻译服务,支持批量处理与并发执行
添加WebUI界面用于触发和监控字幕翻译任务
新增完整单元测试覆盖翻译功能全流程
2026-06-11 10:09:58 +08:00
83 changed files with 9046 additions and 2022 deletions

5
.gitignore vendored
View File

@ -1,6 +1,8 @@
.DS_Store .DS_Store
/config.toml /config.toml
/storage/ /storage/
.venv/
venv/
/.idea/ /.idea/
/app/services/__pycache__ /app/services/__pycache__
/app/__pycache__/ /app/__pycache__/
@ -51,6 +53,9 @@ tests/*
!tests/test_script_service_documentary_unittest.py !tests/test_script_service_documentary_unittest.py
!tests/test_generate_narration_script_documentary_unittest.py !tests/test_generate_narration_script_documentary_unittest.py
!tests/test_generate_script_docu_unittest.py !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/reddit-community
docs/wechat-0.8 docs/wechat-0.8

1
.python-version Normal file
View File

@ -0,0 +1 @@
3.12

View File

@ -15,16 +15,18 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
pkg-config \ pkg-config \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# 升级 pip 并创建虚拟环境 # 安装 uv 并创建构建虚拟环境
RUN python -m pip install --upgrade pip setuptools wheel && \ RUN python -m pip install --upgrade pip setuptools wheel uv
python -m venv /opt/venv
# 激活虚拟环境 # 让 uv 将项目依赖同步到运行阶段复用的虚拟环境
ENV PATH="/opt/venv/bin:$PATH" ENV UV_PROJECT_ENVIRONMENT="/opt/venv" \
UV_LINK_MODE=copy \
UV_COMPILE_BYTECODE=1 \
PATH="/opt/venv/bin:$PATH"
# 复制 requirements.txt 并使用镜像安装 Python 依赖 # 复制 uv 项目文件并安装 Python 依赖
COPY requirements.txt . COPY pyproject.toml uv.lock ./
RUN pip install --no-cache-dir -i https://pypi.tuna.tsinghua.edu.cn/simple -r requirements.txt RUN uv sync --frozen --no-dev
# 运行阶段 # 运行阶段
FROM python:3.12-slim-bookworm FROM python:3.12-slim-bookworm

40
LICENSE
View File

@ -1,18 +1,16 @@
Modified MIT License - Non-Commercial Use Only MIT License
Copyright (c) 2024 linyq Copyright (c) 2024 linyq
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to use, of this software and associated documentation files (the "Software"), to deal
copy, modify, merge, publish, and distribute the Software, subject to the in the Software without restriction, including without limitation the rights
following conditions: to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
1. The Software is provided **for personal, educational, or research purposes only**. The above copyright notice and this permission notice shall be included in all
2. Commercial use of the Software, including but not limited to incorporating copies or substantial portions of the Software.
it into paid products, services, or platforms, is strictly prohibited
without prior written permission from the copyright holder.
3. The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
@ -21,25 +19,3 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE. SOFTWARE.
-------------------------------------------------------
中文说明(仅供参考,以英文条款为准):
修改后的 MIT 协议 - 仅限非商业用途
版权所有 (c) 2024 linyq
任何人均可免费获取本软件及相关文档的副本,并享有以下权利:
- 使用、复制、修改、合并、发布、分发本软件;
但必须遵守以下条件:
1. 本软件仅限用于 **个人、学习或科研用途**。
2. 严禁任何商业用途,包括但不限于将本软件用于收费产品、商业服务、
SaaS 平台或任何形式的营利行为。若需商业授权,必须事先获得版权所有者书面许可。
3. 上述版权声明及许可条款必须包含在本软件的所有副本或重要部分中。
免责声明:
本软件按“现状”提供,不附带任何明示或暗示的担保,包括但不限于
适销性、特定用途适用性和非侵权担保。作者或版权持有人在任何情况下
不对因使用本软件或与本软件有关的行为造成的任何损害承担责任。

View File

@ -33,6 +33,7 @@ NarratoAI is an automated video narration tool that provides an all-in-one solut
</div> </div>
## Latest News ## Latest News
- 2026.07.02 Released version 0.8.4 with Doubao TTS API Key setup and legacy credential compatibility
- 2026.04.03 Released version 0.7.8, refactored the documentary frame-analysis pipeline with a shared service and improved extraction, caching, vision batching, and narration generation - 2026.04.03 Released version 0.7.8, refactored the documentary frame-analysis pipeline with a shared service and improved extraction, caching, vision batching, and narration generation
- 2025.05.11 Released new version 0.6.0, supports **short drama commentary** and optimized editing process - 2025.05.11 Released new version 0.6.0, supports **short drama commentary** and optimized editing process
- 2025.03.06 Released new version 0.5.2, supports DeepSeek R1 and DeepSeek V3 models for short drama mixing - 2025.03.06 Released new version 0.5.2, supports DeepSeek R1 and DeepSeek V3 models for short drama mixing
@ -72,6 +73,7 @@ Below is a screenshot of this person's x (Twitter) homepage
- [x] Optimized the story generation process and improved the generation effect - [x] Optimized the story generation process and improved the generation effect
- [x] Released version 0.3.5 integration package - [x] Released version 0.3.5 integration package
- [x] Support Alibaba Qwen2-VL large model for video understanding - [x] Support Alibaba Qwen2-VL large model for video understanding
- [x] Support TwelveLabs Pegasus as an optional video-understanding backend (analyzes footage natively to drive highlight selection and commentary; opt-in, set `vision_llm_provider = "twelvelabs"`)
- [x] Support short drama commentary - [x] Support short drama commentary
- [x] One-click merge materials - [x] One-click merge materials
- [x] One-click transcription - [x] One-click transcription
@ -89,6 +91,17 @@ Below is a screenshot of this person's x (Twitter) homepage
- Windows 10/11 or MacOS 11.0 or above - Windows 10/11 or MacOS 11.0 or above
- [Python 3.12+](https://www.python.org/downloads/) - [Python 3.12+](https://www.python.org/downloads/)
## Local Run 🚀
```bash
git clone https://github.com/linyqh/NarratoAI.git
cd NarratoAI
uv sync
cp config.example.toml config.toml
uv run streamlit run webui.py --server.maxUploadSize=2048
```
## Feedback & Suggestions 📢 ## Feedback & Suggestions 📢
👏 1. You can submit [issue](https://github.com/linyqh/NarratoAI/issues) or [pull request](https://github.com/linyqh/NarratoAI/pulls) 👏 1. You can submit [issue](https://github.com/linyqh/NarratoAI/issues) or [pull request](https://github.com/linyqh/NarratoAI/pulls)

View File

@ -3,22 +3,12 @@
<h3 align="center">一站式 AI 影视解说+自动化剪辑工具🎬🎞️ </h3> <h3 align="center">一站式 AI 影视解说+自动化剪辑工具🎬🎞️ </h3>
<p align="center"> <p align="center">
📖 <a href="README-en.md">English</a> | 简体中文 | <a href="https://www.narratoai.cn">☁️ <b>云端版入口 (NarratoAI.cn)</b></a> 📖 <a href="README-en.md">English</a> | 简体中文 | <a href="https://www.narratoai.co">☁️ <b>云端版入口 (NarratoAI.co)</b></a>
</p> </p>
<div align="center">
<br> <br>
> **🔥 隆重推荐VibeCut 的新范式 —— [speclip.com](https://speclip.com)** NarratoAI 是一款自动化影视解说工具,基于 LLM 实现文案撰写、自动化视频剪辑、配音和字幕生成的一站式流程,助力高效内容创作。支持本地部署开源版及 [云端托管版](https://www.narratoai.co)。
>
> **一个真正意义上的视频剪辑 Agent像聊天(vibecoding)一样剪辑视频。**
> **[👉 点击立即免费下载 Speclip](https://speclip.com)**
</div>
<br>
NarratoAI 是一款自动化影视解说工具,基于 LLM 实现文案撰写、自动化视频剪辑、配音和字幕生成的一站式流程,助力高效内容创作。支持本地部署开源版及 [云端托管版](https://www.narratoai.cn)。
<br> <br>
@ -37,10 +27,11 @@ NarratoAI 是一款自动化影视解说工具,基于 LLM 实现文案撰写
![](docs/index-zh.png) ![](docs/index-zh.png)
</div> </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.06.10 发布新版本 0.8.1**大版本更新**,优化多个核心流程
- 2026.04.27 发布新版本 0.7.9,新增 **Fun-ASR一键转录字幕** - 2026.04.27 发布新版本 0.7.9,新增 **Fun-ASR一键转录字幕**
- 2026.04.03 发布新版本 0.7.8,重构纪录片逐帧分析链路,统一共享服务并优化抽帧、缓存、视觉并发与文案生成流程 - 2026.04.03 发布新版本 0.7.8,重构纪录片逐帧分析链路,统一共享服务并优化抽帧、缓存、视觉并发与文案生成流程
@ -57,6 +48,17 @@ NarratoAI 是一款自动化影视解说工具,基于 LLM 实现文案撰写
- 2024.11.10 发布官方文档,详情参见 [官方文档](https://p9mf6rjv3c.feishu.cn/wiki/SP8swLLZki5WRWkhuFvc2CyInDg) - 2024.11.10 发布官方文档,详情参见 [官方文档](https://p9mf6rjv3c.feishu.cn/wiki/SP8swLLZki5WRWkhuFvc2CyInDg)
- 2024.11.10 发布新版本 v0.3.5;优化视频剪辑流程, - 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元! > 即日起全面支持硅基流动注册即享2000万免费Token价值16元平台配额剪辑10分钟视频仅需0.1元!
@ -75,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 改名后售卖,下面是部分截图,请大家务必提高警惕,切勿上当受骗**_ _**1. NarratoAI 是一款完全免费的软件,近期在社交媒体(抖音,B站等)上发现,有人将 NarratoAI 改名后售卖,下面是部分截图,请大家务必提高警惕,切勿上当受骗**_
--- ---
@ -93,6 +97,7 @@ _**1. NarratoAI 是一款完全免费的软件,近期在社交媒体(抖音,B
- [x] 优化剧情生成流程,提升生成效果 - [x] 优化剧情生成流程,提升生成效果
- [x] 发布 0.3.5 整合包 - [x] 发布 0.3.5 整合包
- [x] 支持阿里 Qwen2-VL 大模型理解视频 - [x] 支持阿里 Qwen2-VL 大模型理解视频
- [x] 支持 TwelveLabs Pegasus 作为可选的视频理解后端(原生理解整段画面以挑选高光、生成解说;可选启用,设置 `vision_llm_provider = "twelvelabs"`
- [x] 支持短剧混剪 - [x] 支持短剧混剪
- [x] 一键合并素材 - [x] 一键合并素材
- [x] 一键转录 - [x] 一键转录
@ -102,11 +107,38 @@ _**1. NarratoAI 是一款完全免费的软件,近期在社交媒体(抖音,B
- [ ] 主角人脸匹配 - [ ] 主角人脸匹配
- [ ] 支持根据口播,文案,视频素材自动匹配 - [ ] 支持根据口播,文案,视频素材自动匹配
- [X] 支持更多 TTS 引擎 - [X] 支持更多 TTS 引擎
- [X] 支持可选的 AI 自动配乐
- [ ] ... - [ ] ...
## 快速启动 🚀 ## 快速启动 🚀
### 方式一macos Docker 部署macos 推荐) ### 方式一:整合包(推荐)
> 下载地址:[https://cutagent.online/](https://cutagent.online/)
请下载与系统对应的整合包并完整解压;不要单独移动其中的 `NarratoAI``runtime``tools` 等目录。Windows 使用 x64 版本macOS 使用 Apple SiliconM1/M2/M3/M4arm64 版本。
#### Windows
1. 打开解压后的 `NarratoAI-windows-x64` 目录。
2. 先双击 `update-windows.bat` 更新项目,等待窗口提示更新完成。
3. 再双击 `start.bat` 启动应用,并保持启动窗口打开。
4. 在浏览器访问 `http://127.0.0.1:8501`
#### macOSApple Silicon
1. 打开解压后的 `NarratoAI-macos-arm64` 目录。
2. 若系统阻止打开脚本,请在“终端”中执行(将路径替换为实际解压位置):
```bash
xattr -cr "/path/to/NarratoAI-macos-arm64"
chmod +x "/path/to/NarratoAI-macos-arm64/"*.command
```
3. 先双击 `update-macos.command` 更新项目,等待更新完成。
4. 再双击 `start-macos.command` 启动应用,并保持终端窗口打开。
5. 在浏览器访问 `http://127.0.0.1:8501`
### 方式二macos Docker 部署macos 推荐)
```bash ```bash
# 1. 克隆项目 # 1. 克隆项目
git clone https://github.com/linyqh/NarratoAI.git git clone https://github.com/linyqh/NarratoAI.git
@ -118,17 +150,14 @@ docker compose up -d
# 3. 访问应用 # 3. 访问应用
# 浏览器打开 http://localhost:8501 # 浏览器打开 http://localhost:8501
``` ```
### 方式二整合包Windows 推荐)
> *关注微信公众号 **NarratoAI 助手** 右下角菜单栏获取下载链接*
### 方式三:本地运行 ### 方式三:本地运行
```bash ```bash
# 1. 克隆项目 # 1. 克隆项目
git clone https://github.com/linyqh/NarratoAI.git git clone https://github.com/linyqh/NarratoAI.git
cd NarratoAI cd NarratoAI
# 2. 安装依赖 # 2. 使用 uv 安装依赖
pip install -r requirements.txt uv sync
# 3. 复制配置文件 # 3. 复制配置文件
cp config.example.toml config.toml cp config.example.toml config.toml
@ -136,7 +165,7 @@ cp config.example.toml config.toml
# 4. 编辑 config.toml配置你的 API 密钥 # 4. 编辑 config.toml配置你的 API 密钥
# 5. 启动应用 # 5. 启动应用
streamlit run webui.py --server.maxUploadSize=2048 uv run streamlit run webui.py --server.maxUploadSize=2048
# 6. 访问应用 # 6. 访问应用
# 浏览器打开 http://localhost:8501 # 浏览器打开 http://localhost:8501

View File

@ -10,14 +10,33 @@ root_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.realpath(__fi
config_file = f"{root_dir}/config.toml" config_file = f"{root_dir}/config.toml"
version_file = f"{root_dir}/project_version" version_file = f"{root_dir}/project_version"
INDEXTTS_ENGINE = "indextts" INDEXTTS_ENGINE = "indextts"
INDEXTTS_DISPLAY_NAME = "IndexTTS-1.5" INDEXTTS_DISPLAY_NAME = "IndexTTS-1.5-windows"
INDEXTTS_MACOS_ENGINE = "indextts_macos"
INDEXTTS_MACOS_DISPLAY_NAME = "IndexTTS-1.5-macOS"
INDEXTTS2_ENGINE = "indextts2" INDEXTTS2_ENGINE = "indextts2"
INDEXTTS2_DISPLAY_NAME = "IndexTTS-2" INDEXTTS2_DISPLAY_NAME = "IndexTTS-2"
OMNIVOICE_ENGINE = "omnivoice" OMNIVOICE_ENGINE = "omnivoice"
OMNIVOICE_DISPLAY_NAME = "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_VOICE_PREFIX = f"{INDEXTTS_ENGINE}:"
INDEXTTS_MACOS_VOICE_PREFIX = f"{INDEXTTS_MACOS_ENGINE}:"
INDEXTTS2_VOICE_PREFIX = f"{INDEXTTS2_ENGINE}:" INDEXTTS2_VOICE_PREFIX = f"{INDEXTTS2_ENGINE}:"
OMNIVOICE_VOICE_PREFIX = f"{OMNIVOICE_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"),
("sad", "vec_sad"),
("afraid", "vec_afraid"),
("disgusted", "vec_disgusted"),
("melancholic", "vec_melancholic"),
("surprised", "vec_surprised"),
("calm", "vec_calm"),
)
def normalize_tts_engine_name(tts_engine: str) -> str: def normalize_tts_engine_name(tts_engine: str) -> str:
@ -28,6 +47,32 @@ def normalize_indextts_voice_prefix(voice_name: str) -> str:
return voice_name return voice_name
def get_indextts2_pack_emotion(indextts2_config) -> str:
"""Return the MLX Pack emotion string for current or legacy settings."""
if not isinstance(indextts2_config, dict):
return ""
configured_emotion = str(indextts2_config.get("emotion", "")).strip()
if configured_emotion:
return configured_emotion
emotion_mode = indextts2_config.get("emotion_mode", "speaker")
if emotion_mode == "text":
return str(indextts2_config.get("emotion_text", "")).strip()
if emotion_mode != "vector":
return ""
weights = []
for emotion, field in INDEXTTS2_EMOTION_VECTOR_FIELDS:
try:
weight = float(indextts2_config.get(field, 0.0))
except (TypeError, ValueError):
continue
if weight > 0:
weights.append(f"{emotion}:{weight:g}")
return ",".join(weights)
def _is_legacy_indextts2_config(indextts2_config) -> bool: def _is_legacy_indextts2_config(indextts2_config) -> bool:
if not isinstance(indextts2_config, dict): if not isinstance(indextts2_config, dict):
return False return False
@ -133,8 +178,11 @@ def save_config():
_cfg["tts_qwen"] = tts_qwen _cfg["tts_qwen"] = tts_qwen
_cfg["fun_asr"] = fun_asr _cfg["fun_asr"] = fun_asr
_cfg["indextts"] = indextts _cfg["indextts"] = indextts
_cfg["indextts_macos"] = indextts_macos
_cfg["indextts2"] = indextts2 _cfg["indextts2"] = indextts2
_cfg["omnivoice"] = omnivoice _cfg["omnivoice"] = omnivoice
_cfg["voxcpm_05b"] = voxcpm_05b
_cfg["voxcpm_2b"] = voxcpm_2b
_cfg["doubaotts"] = doubaotts _cfg["doubaotts"] = doubaotts
f.write(toml.dumps(_cfg)) f.write(toml.dumps(_cfg))
@ -151,8 +199,11 @@ frames = _cfg.get("frames", {})
tts_qwen = _cfg.get("tts_qwen", {}) tts_qwen = _cfg.get("tts_qwen", {})
fun_asr = _cfg.get("fun_asr", {}) fun_asr = _cfg.get("fun_asr", {})
indextts = _cfg.get("indextts", {}) indextts = _cfg.get("indextts", {})
indextts_macos = _cfg.get("indextts_macos", {})
indextts2 = _cfg.get("indextts2", {}) indextts2 = _cfg.get("indextts2", {})
omnivoice = _cfg.get("omnivoice", {}) omnivoice = _cfg.get("omnivoice", {})
voxcpm_05b = _cfg.get("voxcpm_05b", {})
voxcpm_2b = _cfg.get("voxcpm_2b", {})
doubaotts = _cfg.get("doubaotts", {}) doubaotts = _cfg.get("doubaotts", {})
hostname = socket.gethostname() 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_LLM_PROVIDER = DEFAULT_OPENAI_COMPATIBLE_PROVIDER
DEFAULT_TEXT_OPENAI_MODEL_NAME = "Pro/zai-org/GLM-5" DEFAULT_TEXT_OPENAI_MODEL_NAME = "Pro/zai-org/GLM-5"
DEFAULT_TEXT_OPENAI_FAST_MODEL_NAME = ""
DEFAULT_LLM_GENERATION_CONFIG = { DEFAULT_LLM_GENERATION_CONFIG = {
"temperature": 1.0, "temperature": 1.0,
@ -33,6 +34,7 @@ DEFAULT_LLM_APP_CONFIG = {
"vision_openai_base_url": DEFAULT_OPENAI_COMPATIBLE_BASE_URL, "vision_openai_base_url": DEFAULT_OPENAI_COMPATIBLE_BASE_URL,
"text_llm_provider": DEFAULT_TEXT_LLM_PROVIDER, "text_llm_provider": DEFAULT_TEXT_LLM_PROVIDER,
"text_openai_model_name": DEFAULT_TEXT_OPENAI_MODEL_NAME, "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_api_key": "",
"text_openai_base_url": DEFAULT_OPENAI_COMPATIBLE_BASE_URL, "text_openai_base_url": DEFAULT_OPENAI_COMPATIBLE_BASE_URL,
"tavily_api_key": "", "tavily_api_key": "",
@ -69,6 +71,31 @@ def normalize_openai_compatible_model_name(
return normalized 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( def get_openai_compatible_ui_values(
full_model_name: str, full_model_name: str,
default_model: str, default_model: str,

View File

@ -1,6 +1,7 @@
import tempfile import tempfile
import unittest import unittest
from pathlib import Path from pathlib import Path
from unittest.mock import patch
try: try:
import tomllib import tomllib
@ -11,10 +12,35 @@ from app.config import config as cfg
from app.config.defaults import ( from app.config.defaults import (
get_openai_compatible_ui_values, get_openai_compatible_ui_values,
normalize_openai_compatible_model_name, normalize_openai_compatible_model_name,
resolve_text_model_name,
) )
class ConfigBootstrapDefaultsTests(unittest.TestCase): class ConfigBootstrapDefaultsTests(unittest.TestCase):
def test_save_config_keeps_macos_tts_settings_independent(self):
macos_settings = {
"api_url": "http://127.0.0.1:7866",
"reference_audio": "/tmp/macos-reference.wav",
"speed": 1.1,
}
windows_settings = {
"api_url": "http://127.0.0.1:8081/tts",
"reference_audio": "/tmp/windows-reference.wav",
}
with tempfile.TemporaryDirectory() as tmp_dir:
config_path = Path(tmp_dir) / "config.toml"
with (
patch.object(cfg, "config_file", str(config_path)),
patch.object(cfg, "indextts_macos", dict(macos_settings)),
patch.object(cfg, "indextts", dict(windows_settings)),
):
cfg.save_config()
saved_config = tomllib.loads(config_path.read_text(encoding="utf-8"))
self.assertEqual(macos_settings, saved_config["indextts_macos"])
self.assertEqual(windows_settings, saved_config["indextts"])
def test_load_config_bootstraps_webui_llm_defaults(self): def test_load_config_bootstraps_webui_llm_defaults(self):
original_root_dir = cfg.root_dir original_root_dir = cfg.root_dir
original_config_file = cfg.config_file original_config_file = cfg.config_file
@ -57,11 +83,13 @@ hide_config = true
self.assertEqual(0.95, config_data["app"]["vision_openai_top_p"]) self.assertEqual(0.95, config_data["app"]["vision_openai_top_p"])
self.assertEqual("openai", config_data["app"]["text_llm_provider"]) 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("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("https://api.siliconflow.cn/v1", config_data["app"]["text_openai_base_url"])
self.assertEqual(1.0, config_data["app"]["text_openai_temperature"]) self.assertEqual(1.0, config_data["app"]["text_openai_temperature"])
self.assertEqual(0.95, config_data["app"]["text_openai_top_p"]) 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("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("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"]) self.assertTrue(saved_config["app"]["hide_config"])
def test_legacy_indextts2_config_is_migrated_to_indextts_15(self): def test_legacy_indextts2_config_is_migrated_to_indextts_15(self):
@ -102,6 +130,23 @@ hide_config = true
class OpenAICompatibleModelDefaultsTests(unittest.TestCase): 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): def test_ui_keeps_full_model_name_and_openai_provider(self):
provider, model_name = get_openai_compatible_ui_values( provider, model_name = get_openai_compatible_ui_values(
"Qwen/Qwen3.5-122B-A10B", "Qwen/Qwen3.5-122B-A10B",

View File

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

View File

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

View File

@ -1,6 +1,8 @@
""" """
视频脚本生成pipeline串联各个处理步骤 视频脚本生成pipeline串联各个处理步骤
""" """
import json
import os
from typing import Any, Dict, Optional from typing import Any, Dict, Optional
from loguru import logger from loguru import logger
@ -16,6 +18,10 @@ def generate_script_result(
base_url: str = None, base_url: str = None,
custom_clips: int = 5, custom_clips: int = 5,
provider: str = None, provider: str = None,
video_paths=None,
plot_analysis: Optional[str] = None,
short_name: str = "",
drama_genre: str = "",
*, *,
srt_path: Optional[str] = None, srt_path: Optional[str] = None,
subtitle_content: Optional[str] = None, subtitle_content: Optional[str] = None,
@ -30,6 +36,10 @@ def generate_script_result(
base_url: API基础URL可选 base_url: API基础URL可选
custom_clips: 自定义片段数量默认5 custom_clips: 自定义片段数量默认5
provider: LLM服务提供商可选 provider: LLM服务提供商可选
video_paths: 原始视频路径列表用于生成 video_id/video_name
plot_analysis: 已完成的剧情理解文本提供时会跳过混剪内部剧情理解
short_name: 短剧名称
drama_genre: 短剧类型
srt_path: 字幕文件路径向后兼容 srt_path: 字幕文件路径向后兼容
subtitle_content: 字幕文本内容 subtitle_content: 字幕文本内容
subtitle_file_path: 字幕文件路径推荐 subtitle_file_path: 字幕文件路径推荐
@ -56,10 +66,23 @@ def generate_script_result(
provider=provider, provider=provider,
srt_path=resolved_path, srt_path=resolved_path,
subtitle_content=resolved_content, subtitle_content=resolved_content,
plot_analysis=plot_analysis,
video_paths=video_paths,
short_name=short_name,
drama_genre=drama_genre,
) )
if openai_analysis.get("script_items"):
final_script = openai_analysis["script_items"]
if not output_path or not str(output_path).strip():
raise ValueError("output_path不能为空")
os.makedirs(os.path.dirname(str(output_path)) or ".", exist_ok=True)
with open(output_path, "w", encoding="utf-8") as f:
json.dump(final_script, f, ensure_ascii=False, indent=4)
logger.info(f"短剧混剪脚本生成完成:{output_path}")
else:
adjusted_results = openai_analysis['plot_points'] adjusted_results = openai_analysis['plot_points']
final_script = merge_script(adjusted_results, output_path) final_script = merge_script(adjusted_results, output_path, video_paths=video_paths)
return {"status": "success", "script": final_script} return {"status": "success", "script": final_script}
@ -79,6 +102,10 @@ def generate_script(
base_url: str = None, base_url: str = None,
custom_clips: int = 5, custom_clips: int = 5,
provider: str = None, provider: str = None,
video_paths=None,
plot_analysis: Optional[str] = None,
short_name: str = "",
drama_genre: str = "",
*, *,
subtitle_content: Optional[str] = None, subtitle_content: Optional[str] = None,
subtitle_file_path: Optional[str] = None, subtitle_file_path: Optional[str] = None,
@ -93,6 +120,10 @@ def generate_script(
base_url: API基础URL可选 base_url: API基础URL可选
custom_clips: 自定义片段数量默认5 custom_clips: 自定义片段数量默认5
provider: LLM服务提供商可选 provider: LLM服务提供商可选
video_paths: 原始视频路径列表用于生成 video_id/video_name
plot_analysis: 已完成的剧情理解文本
short_name: 短剧名称
drama_genre: 短剧类型
subtitle_content: 字幕文本内容可选 subtitle_content: 字幕文本内容可选
subtitle_file_path: 字幕文件路径推荐使用可选 subtitle_file_path: 字幕文件路径推荐使用可选
@ -110,6 +141,10 @@ def generate_script(
base_url=base_url, base_url=base_url,
custom_clips=custom_clips, custom_clips=custom_clips,
provider=provider, provider=provider,
video_paths=video_paths,
plot_analysis=plot_analysis,
short_name=short_name,
drama_genre=drama_genre,
srt_path=srt_path, srt_path=srt_path,
subtitle_content=subtitle_content, subtitle_content=subtitle_content,
subtitle_file_path=subtitle_file_path, subtitle_file_path=subtitle_file_path,

View File

@ -1,11 +1,16 @@
""" """
使用统一LLM服务分析字幕文件返回剧情梗概和爆点 使用统一LLM服务分析字幕文件返回剧情梗概和爆点
""" """
import os
import traceback import traceback
import json import json
from loguru import logger from loguru import logger
from app.services.subtitle_text import has_timecodes, normalize_subtitle_text, read_subtitle_text from app.services.subtitle_text import has_timecodes, normalize_subtitle_text, read_subtitle_text
from app.services.short_drama_narration_validation import (
build_subtitle_index,
parse_script_timestamp_range,
)
# 导入新的提示词管理系统 # 导入新的提示词管理系统
from app.services.prompts import PromptManager from app.services.prompts import PromptManager
# 导入统一LLM服务 # 导入统一LLM服务
@ -14,6 +19,176 @@ from app.services.llm.unified_service import UnifiedLLMService
from app.services.llm.migration_adapter import _run_async_safely from app.services.llm.migration_adapter import _run_async_safely
def _normalize_paths(paths):
if isinstance(paths, str):
paths = [paths]
if not paths:
return []
normalized_paths = []
seen = set()
for path in paths:
if not isinstance(path, str):
continue
path = path.strip()
if not path or path in seen:
continue
normalized_paths.append(path)
seen.add(path)
return normalized_paths
def _coerce_positive_int(value):
try:
number = int(value)
except (TypeError, ValueError):
return None
return number if number > 0 else None
def _match_video_id_by_name(video_name, video_paths):
video_name = os.path.basename(str(video_name or "").strip())
if not video_name:
return None
for index, video_path in enumerate(video_paths, start=1):
if os.path.basename(video_path) == video_name:
return index
return None
def _default_video_name(video_id, video_paths):
if 1 <= video_id <= len(video_paths):
return os.path.basename(video_paths[video_id - 1])
return ""
def _normalize_short_mix_items(items, video_paths, subtitle_content):
if not isinstance(items, list) or not items:
raise ValueError("短剧混剪脚本 items 必须是非空数组")
normalized_video_paths = _normalize_paths(video_paths)
subtitle_index = build_subtitle_index(subtitle_content, normalized_video_paths)
available_video_ids = {cue.video_id for cue in subtitle_index}
if normalized_video_paths:
available_video_ids.update(range(1, len(normalized_video_paths) + 1))
normalized_items = []
ranges_by_video = {}
for index, raw_item in enumerate(items, start=1):
if not isinstance(raw_item, dict):
raise ValueError(f"{index} 个混剪片段必须是对象")
item_id = index
video_id = (
_match_video_id_by_name(raw_item.get("video_name") or raw_item.get("source_video"), normalized_video_paths)
or _coerce_positive_int(raw_item.get("video_id") or raw_item.get("video_index"))
or 1
)
if available_video_ids and video_id not in available_video_ids:
raise ValueError(f"片段 {item_id} 的 video_id={video_id} 不在已选视频范围内")
try:
start_ms, end_ms, timestamp = parse_script_timestamp_range(raw_item.get("timestamp", ""))
except ValueError as exc:
raise ValueError(f"片段 {item_id}: {exc}") from exc
if start_ms >= end_ms:
raise ValueError(f"片段 {item_id} 的开始时间必须早于结束时间")
video_cues = [cue for cue in subtitle_index if cue.video_id == video_id]
if video_cues:
min_start = min(cue.start_ms for cue in video_cues)
max_end = max(cue.end_ms for cue in video_cues)
if start_ms < min_start or end_ms > max_end:
raise ValueError(f"片段 {item_id} 的时间戳不在视频 {video_id} 的字幕范围内")
if not any(start_ms < cue.end_ms and end_ms > cue.start_ms for cue in video_cues):
raise ValueError(f"片段 {item_id} 的时间戳没有命中视频 {video_id} 的字幕内容")
picture = str(
raw_item.get("picture")
or raw_item.get("title")
or raw_item.get("narrative_function")
or raw_item.get("intent")
or raw_item.get("story_role")
or ""
).strip()
if not picture:
raise ValueError(f"片段 {item_id} 的 picture 不能为空")
video_name = str(raw_item.get("video_name") or "").strip()
if normalized_video_paths:
video_name = _default_video_name(video_id, normalized_video_paths)
normalized_items.append(
{
"_id": item_id,
"video_id": video_id,
"video_name": video_name,
"timestamp": timestamp,
"picture": picture,
"narration": f"播放原片{item_id}",
"OST": 1,
}
)
ranges_by_video.setdefault(video_id, []).append((start_ms, end_ms, item_id))
for video_id, ranges in ranges_by_video.items():
ranges = sorted(ranges, key=lambda item: (item[0], item[1], item[2]))
previous_start, previous_end, previous_id = ranges[0]
for start_ms, end_ms, item_id in ranges[1:]:
if start_ms < previous_end:
raise ValueError(f"视频 {video_id} 的片段 {item_id} 与片段 {previous_id} 时间戳重叠")
if end_ms > previous_end:
previous_start, previous_end, previous_id = start_ms, end_ms, item_id
return normalized_items
def _generate_short_mix_script(
*,
subtitle_content,
plot_analysis,
custom_clips,
provider,
model_name,
api_key,
base_url,
video_paths=None,
short_name="",
drama_genre="",
):
script_generation_prompt = PromptManager.get_prompt(
category="short_drama_editing",
name="script_generation",
parameters={
"drama_name": short_name or "短剧",
"drama_genre": drama_genre or "短剧",
"plot_analysis": plot_analysis,
"subtitle_content": subtitle_content,
"custom_clips": int(custom_clips or 5),
},
)
response = _run_async_safely(
UnifiedLLMService.generate_text,
prompt=script_generation_prompt,
provider=provider,
model=model_name,
api_key=api_key,
base_url=base_url,
temperature=0.1,
max_tokens=4000,
)
from webui.tools.generate_short_summary import parse_and_fix_json
script_data = parse_and_fix_json(response)
if not script_data:
raise ValueError("无法解析短剧混剪脚本JSON")
script_items = script_data.get("items") or script_data.get("segments") or script_data.get("plot_points")
return _normalize_short_mix_items(script_items, video_paths, subtitle_content)
def analyze_subtitle( def analyze_subtitle(
model_name: str, model_name: str,
api_key: str = None, api_key: str = None,
@ -21,7 +196,11 @@ def analyze_subtitle(
custom_clips: int = 5, custom_clips: int = 5,
provider: str = None, provider: str = None,
srt_path: str = None, srt_path: str = None,
subtitle_content: str = None subtitle_content: str = None,
plot_analysis: str = None,
video_paths=None,
short_name: str = "",
drama_genre: str = "",
) -> dict: ) -> dict:
"""分析字幕内容,返回完整的分析结果 """分析字幕内容,返回完整的分析结果
@ -33,6 +212,10 @@ def analyze_subtitle(
provider (str, optional): LLM服务提供商. Defaults to None. provider (str, optional): LLM服务提供商. Defaults to None.
srt_path (str, optional): SRT字幕文件路径与subtitle_content二选一 srt_path (str, optional): SRT字幕文件路径与subtitle_content二选一
subtitle_content (str, optional): SRT字幕文本内容与srt_path二选一 subtitle_content (str, optional): SRT字幕文本内容与srt_path二选一
plot_analysis (str, optional): 已审核/缓存的剧情理解文本提供时直接进入混剪脚本生成
video_paths (list, optional): 原始视频路径列表用于补齐 video_id/video_name
short_name (str, optional): 短剧名称
drama_genre (str, optional): 短剧类型
Returns: Returns:
dict: 包含剧情梗概和结构化的时间段分析的字典 dict: 包含剧情梗概和结构化的时间段分析的字典
@ -87,6 +270,27 @@ def analyze_subtitle(
logger.info(f"使用LLM服务分析字幕提供商: {provider}, 模型: {model_name}") logger.info(f"使用LLM服务分析字幕提供商: {provider}, 模型: {model_name}")
if plot_analysis and str(plot_analysis).strip():
logger.info("使用已有剧情理解直接生成短剧混剪脚本")
script_items = _generate_short_mix_script(
subtitle_content=subtitle_content,
plot_analysis=str(plot_analysis).strip(),
custom_clips=custom_clips,
provider=provider,
model_name=model_name,
api_key=api_key,
base_url=base_url,
video_paths=video_paths,
short_name=short_name,
drama_genre=drama_genre,
)
return {
"summary": str(plot_analysis).strip(),
"plot_titles": [],
"plot_points": [],
"script_items": script_items,
}
# 使用新的提示词管理系统 # 使用新的提示词管理系统
subtitle_analysis_prompt = PromptManager.get_prompt( subtitle_analysis_prompt = PromptManager.get_prompt(
category="short_drama_editing", category="short_drama_editing",
@ -120,6 +324,28 @@ def analyze_subtitle(
logger.info(f"字幕分析完成,找到 {len(summary_data.get('plot_titles', []))} 个关键情节") logger.info(f"字幕分析完成,找到 {len(summary_data.get('plot_titles', []))} 个关键情节")
logger.debug(json.dumps(summary_data, indent=4, ensure_ascii=False)) logger.debug(json.dumps(summary_data, indent=4, ensure_ascii=False))
try:
script_items = _generate_short_mix_script(
subtitle_content=subtitle_content,
plot_analysis=json.dumps(summary_data, ensure_ascii=False, indent=2),
custom_clips=custom_clips,
provider=provider,
model_name=model_name,
api_key=api_key,
base_url=base_url,
video_paths=video_paths,
short_name=short_name,
drama_genre=drama_genre,
)
return {
"summary": summary_data.get("summary", ""),
"plot_titles": summary_data.get("plot_titles", []),
"plot_points": [],
"script_items": script_items,
}
except Exception as direct_script_error:
logger.warning(f"直接生成短剧混剪脚本失败,回退到时间段定位: {direct_script_error}")
# 构建爆点标题列表 # 构建爆点标题列表
plot_titles_text = "" plot_titles_text = ""
logger.info(f"找到 {len(summary_data.get('plot_titles', []))} 个片段") logger.info(f"找到 {len(summary_data.get('plot_titles', []))} 个片段")

View File

@ -8,7 +8,8 @@ from typing import Dict, List
def merge_script( def merge_script(
plot_points: List[Dict], plot_points: List[Dict],
output_path: str output_path: str,
video_paths=None,
): ):
"""合并生成最终脚本 """合并生成最终脚本
@ -19,6 +20,10 @@ def merge_script(
Returns: Returns:
str: 最终合并的脚本 str: 最终合并的脚本
""" """
if isinstance(video_paths, str):
video_paths = [video_paths]
video_paths = [path for path in (video_paths or []) if isinstance(path, str) and path.strip()]
# 创建包含所有信息的临时列表 # 创建包含所有信息的临时列表
final_script = [] final_script = []
@ -29,9 +34,12 @@ def merge_script(
"_id": number, "_id": number,
"timestamp": plot_point["timestamp"], "timestamp": plot_point["timestamp"],
"picture": plot_point["picture"], "picture": plot_point["picture"],
"narration": f"播放原生_{os.urandom(4).hex()}", "narration": f"播放原{number}",
"OST": 1, # OST=0 仅保留解说 OST=2 保留解说和原声 "OST": 1, # OST=0 仅保留解说 OST=2 保留解说和原声
} }
if video_paths:
script_item["video_id"] = 1
script_item["video_name"] = os.path.basename(video_paths[0])
final_script.append(script_item) final_script.append(script_item)
number += 1 number += 1

View File

@ -23,6 +23,7 @@ UPLOAD_POLICY_URL = f"{DASHSCOPE_BASE_URL}/api/v1/uploads"
TRANSCRIPTION_URL = f"{DASHSCOPE_BASE_URL}/api/v1/services/audio/asr/transcription" TRANSCRIPTION_URL = f"{DASHSCOPE_BASE_URL}/api/v1/services/audio/asr/transcription"
TASK_URL_TEMPLATE = f"{DASHSCOPE_BASE_URL}/api/v1/tasks/{{task_id}}" TASK_URL_TEMPLATE = f"{DASHSCOPE_BASE_URL}/api/v1/tasks/{{task_id}}"
MODEL_NAME = "fun-asr" MODEL_NAME = "fun-asr"
LOCAL_FUN_ASR_OPENAI_MODEL = "sensevoice"
LOCAL_FUN_ASR_API_URL = "http://127.0.0.1:7860" LOCAL_FUN_ASR_API_URL = "http://127.0.0.1:7860"
LOCAL_FIRERED_ASR_API_URL = "http://127.0.0.1:7867" LOCAL_FIRERED_ASR_API_URL = "http://127.0.0.1:7867"
TERMINAL_FAILED_STATUSES = {"FAILED", "CANCELED", "UNKNOWN"} TERMINAL_FAILED_STATUSES = {"FAILED", "CANCELED", "UNKNOWN"}
@ -111,18 +112,42 @@ def _local_base_url(api_url: str = "") -> str:
api_url = _normalize_local_api_url(api_url) api_url = _normalize_local_api_url(api_url)
parsed = urlparse(api_url) parsed = urlparse(api_url)
path = parsed.path.rstrip("/") path = parsed.path.rstrip("/")
if path.endswith("/asr"): for suffix in ("/v1/audio/transcriptions", "/v1", "/asr"):
path = path[:-4].rstrip("/") if path.endswith(suffix):
path = path[: -len(suffix)].rstrip("/")
break
return urlunparse(parsed._replace(path=path, params="", query="", fragment="")).rstrip("/") return urlunparse(parsed._replace(path=path, params="", query="", fragment="")).rstrip("/")
def _local_asr_url(api_url: str = "") -> str: def _local_asr_url(api_url: str = "") -> str:
api_url = _normalize_local_api_url(api_url) api_url = _normalize_local_api_url(api_url)
if urlparse(api_url).path.rstrip("/").endswith("/asr"): path = urlparse(api_url).path.rstrip("/")
if path.endswith("/asr"):
return api_url return api_url
if path.endswith("/v1") or path.endswith("/v1/audio/transcriptions"):
return f"{_local_base_url(api_url)}/asr"
return f"{api_url}/asr" return f"{api_url}/asr"
def _local_openai_transcriptions_url(api_url: str = "") -> str:
api_url = _normalize_local_api_url(api_url)
path = urlparse(api_url).path.rstrip("/")
if path.endswith("/v1/audio/transcriptions"):
return api_url
if path.endswith("/v1"):
return f"{api_url}/audio/transcriptions"
return f"{_local_base_url(api_url)}/v1/audio/transcriptions"
def _local_fun_asr_prefers_openai(api_url: str = "") -> bool:
path = urlparse(_normalize_local_api_url(api_url)).path.rstrip("/")
return path.endswith("/v1") or path.endswith("/v1/audio/transcriptions")
def _is_not_found_response(response: requests.Response) -> bool:
return getattr(response, "status_code", 200) == 404
def _absolute_local_download_url(api_url: str, download_url: str) -> str: def _absolute_local_download_url(api_url: str, download_url: str) -> str:
download_url = (download_url or "").strip() download_url = (download_url or "").strip()
if not download_url: if not download_url:
@ -547,28 +572,53 @@ def request_local_fun_asr(
api_url: str = LOCAL_FUN_ASR_API_URL, api_url: str = LOCAL_FUN_ASR_API_URL,
hotword: str = "", hotword: str = "",
enable_spk: Optional[bool] = None, enable_spk: Optional[bool] = None,
model: str = LOCAL_FUN_ASR_OPENAI_MODEL,
timeout: float = 600.0, timeout: float = 600.0,
session=requests, session=requests,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Call the local FunASR-Pack `/asr` API and return its JSON result.""" """Call the local FunASR-Pack API and return its JSON result."""
_require_local_file(local_file) _require_local_file(local_file)
data: dict[str, str] = {} rest_data: dict[str, str] = {}
if hotword.strip(): if hotword.strip():
data["hotword"] = hotword.strip() rest_data["hotword"] = hotword.strip()
if enable_spk is not None: if enable_spk is not None:
data["enable_spk"] = "true" if enable_spk else "false" rest_data["enable_spk"] = "true" if enable_spk else "false"
openai_data: dict[str, str] = {
"model": (model or LOCAL_FUN_ASR_OPENAI_MODEL).strip() or LOCAL_FUN_ASR_OPENAI_MODEL,
"response_format": "verbose_json",
}
if enable_spk is not None:
openai_data["spk"] = "true" if enable_spk else "false"
rest_url = _local_asr_url(api_url)
openai_url = _local_openai_transcriptions_url(api_url)
attempts = [
(openai_url, openai_data),
(rest_url, rest_data),
] if _local_fun_asr_prefers_openai(api_url) else [
(rest_url, rest_data),
(openai_url, openai_data),
]
last_response = None
for index, (url, data) in enumerate(attempts):
with open(local_file, "rb") as file_obj: with open(local_file, "rb") as file_obj:
files = {"file": (_safe_upload_name(local_file), file_obj)} files = {"file": (_safe_upload_name(local_file), file_obj)}
response = _session_post( response = _session_post(
session, session,
_local_asr_url(api_url), url,
data=data, data=data,
files=files, files=files,
timeout=timeout, timeout=timeout,
) )
if index == 0 and _is_not_found_response(response):
last_response = response
continue
return _local_json(response, "调用本地 FunASR-Pack ASR API") return _local_json(response, "调用本地 FunASR-Pack ASR API")
return _local_json(last_response, "调用本地 FunASR-Pack ASR API")
def request_local_firered_asr( def request_local_firered_asr(
local_file: str, local_file: str,
@ -640,6 +690,40 @@ def _local_result_items(result_json: dict[str, Any]):
yield result_json yield result_json
def _openai_segment_ms(value: Any, field_name: str) -> float:
return _timestamp_ms(value, field_name) * 1000
def _blocks_from_openai_segments(result_json: dict[str, Any], max_chars: int) -> list[dict[str, Any]]:
segments = result_json.get("segments") or []
if not isinstance(segments, list):
return []
blocks: list[dict[str, Any]] = []
for segment in segments:
if not isinstance(segment, dict):
continue
text = str(segment.get("text") or "").strip()
if not text:
continue
start = segment.get("start", segment.get("start_time", 0))
end = segment.get("end", segment.get("end_time"))
start_ms = _openai_segment_ms(start, "openai.segment.start")
end_ms = _openai_segment_ms(end, "openai.segment.end") if end is not None else start_ms + 500
blocks.extend(
_blocks_from_sentence(
{
"begin_time": start_ms,
"end_time": end_ms,
"text": text,
"speaker_id": segment.get("speaker"),
},
max_chars=max_chars,
)
)
return blocks
def _blocks_from_local_timestamp(item: dict[str, Any], max_chars: int, max_duration: float) -> list[dict[str, Any]]: def _blocks_from_local_timestamp(item: dict[str, Any], max_chars: int, max_duration: float) -> list[dict[str, Any]]:
text = str(item.get("text") or "").strip() text = str(item.get("text") or "").strip()
timestamps = item.get("timestamp") or [] timestamps = item.get("timestamp") or []
@ -702,7 +786,7 @@ def local_fun_asr_result_to_srt(
max_duration: float = 3.5, max_duration: float = 3.5,
) -> str: ) -> str:
"""Convert a FunASR-Pack JSON response into SRT when the API SRT is unavailable.""" """Convert a FunASR-Pack JSON response into SRT when the API SRT is unavailable."""
blocks: list[dict[str, Any]] = [] blocks = _blocks_from_openai_segments(result_json, max_chars=max_chars)
for item in _local_result_items(result_json): for item in _local_result_items(result_json):
item_blocks = _blocks_from_local_timestamp(item, max_chars, max_duration) item_blocks = _blocks_from_local_timestamp(item, max_chars, max_duration)
if not item_blocks: if not item_blocks:

View File

@ -114,6 +114,10 @@ def _normalize_indextts_reference_audio(params: VideoClipParams) -> None:
tts_config = config.indextts tts_config = config.indextts
voice_prefix = config.INDEXTTS_VOICE_PREFIX voice_prefix = config.INDEXTTS_VOICE_PREFIX
display_name = "IndexTTS-1.5" display_name = "IndexTTS-1.5"
elif params.tts_engine == config.INDEXTTS_MACOS_ENGINE:
tts_config = config.indextts_macos
voice_prefix = config.INDEXTTS_MACOS_VOICE_PREFIX
display_name = config.INDEXTTS_MACOS_DISPLAY_NAME
elif params.tts_engine == config.INDEXTTS2_ENGINE: elif params.tts_engine == config.INDEXTTS2_ENGINE:
tts_config = config.indextts2 tts_config = config.indextts2
voice_prefix = config.INDEXTTS2_VOICE_PREFIX voice_prefix = config.INDEXTTS2_VOICE_PREFIX

View File

@ -17,6 +17,10 @@ import subprocess
from typing import Union, TextIO from typing import Union, TextIO
from app.config import config from app.config import config
from app.utils.openai_base_url_security import (
openai_compatible_base_url_warning,
validate_openai_compatible_base_url,
)
from app.utils.utils import clean_model_output from app.utils.utils import clean_model_output
_max_retries = 5 _max_retries = 5
@ -330,6 +334,10 @@ def _generate_response(prompt: str, llm_provider: str = None) -> str:
azure_endpoint=base_url, azure_endpoint=base_url,
) )
else: else:
base_url = validate_openai_compatible_base_url(base_url)
warning = openai_compatible_base_url_warning(base_url)
if warning:
logger.warning(warning)
client = OpenAI( client = OpenAI(
api_key=api_key, api_key=api_key,
base_url=base_url, base_url=base_url,

View File

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

View File

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

View File

@ -23,8 +23,13 @@ from openai import (
from app.config import config from app.config import config
from app.config.defaults import DEFAULT_LLM_GENERATION_CONFIG, normalize_openai_compatible_model_name from app.config.defaults import DEFAULT_LLM_GENERATION_CONFIG, normalize_openai_compatible_model_name
from app.utils.openai_base_url_security import (
is_trusted_openai_compatible_base_url,
openai_compatible_base_url_warning,
validate_openai_compatible_base_url as _validate_openai_compatible_base_url_value,
)
from .base import TextModelProvider, VisionModelProvider from .base import TextModelProvider, VisionModelProvider
from .exceptions import APICallError, AuthenticationError, ContentFilterError, RateLimitError from .exceptions import APICallError, AuthenticationError, ConfigurationError, ContentFilterError, RateLimitError
def _normalize_model_name(model_name: str) -> str: def _normalize_model_name(model_name: str) -> str:
@ -41,6 +46,17 @@ def _is_content_filter_error(message: str) -> bool:
return "content_filter" in lowered or "safety" in lowered return "content_filter" in lowered or "safety" in lowered
def validate_openai_compatible_base_url(base_url: Optional[str]) -> Optional[str]:
try:
normalized = _validate_openai_compatible_base_url_value(base_url)
except ValueError as exc:
raise ConfigurationError(str(exc), "base_url") from exc
warning = openai_compatible_base_url_warning(normalized)
if warning:
logger.warning(warning)
return normalized
def _clean_json_output(output: str) -> str: def _clean_json_output(output: str) -> str:
"""清理 JSON 输出中的 markdown 包裹。""" """清理 JSON 输出中的 markdown 包裹。"""
output = re.sub(r"^```json\s*", "", output, flags=re.MULTILINE) output = re.sub(r"^```json\s*", "", output, flags=re.MULTILINE)
@ -118,6 +134,7 @@ class _OpenAICompatibleBase:
"""按请求构建 AsyncOpenAI 客户端,支持动态覆盖 api_key / base_url。""" """按请求构建 AsyncOpenAI 客户端,支持动态覆盖 api_key / base_url。"""
api_key = api_key_override or self.api_key api_key = api_key_override or self.api_key
base_url = base_url_override or self.base_url or None base_url = base_url_override or self.base_url or None
base_url = validate_openai_compatible_base_url(base_url)
timeout_seconds: float = timeout_override or config.app.get("llm_text_timeout", 180) timeout_seconds: float = timeout_override or config.app.get("llm_text_timeout", 180)
max_retries: int = max_retries_override or config.app.get("llm_max_retries", 3) max_retries: int = max_retries_override or config.app.get("llm_max_retries", 3)
@ -241,8 +258,9 @@ class OpenAICompatibleTextProvider(_OpenAICompatibleBase, TextModelProvider):
response_format: Optional[str], response_format: Optional[str],
kwargs: Dict[str, Any], kwargs: Dict[str, Any],
) -> Dict[str, Any]: ) -> Dict[str, Any]:
model_name = _normalize_model_name(self.model_name)
generation_kwargs = dict(kwargs) 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) temperature_override = generation_kwargs.pop("temperature", None)
if temperature_override is None and temperature != 1.0: if temperature_override is None and temperature != 1.0:
temperature_override = temperature temperature_override = temperature

View File

@ -12,7 +12,7 @@ def register_all_providers():
""" """
注册所有提供商 注册所有提供商
当前实现注册 OpenAI 兼容统一接口 当前实现注册 OpenAI 兼容统一接口并可选注册 TwelveLabs Pegasus 视频理解
""" """
# 在函数内部导入,避免循环依赖 # 在函数内部导入,避免循环依赖
from ..manager import LLMServiceManager from ..manager import LLMServiceManager
@ -32,6 +32,14 @@ def register_all_providers():
logger.info("✅ OpenAI 兼容提供商注册完成") logger.info("✅ OpenAI 兼容提供商注册完成")
# ===== 注册 TwelveLabs Pegasus 视频理解(可选视觉提供商)=====
# 仅当用户将 vision_llm_provider 设为 "twelvelabs" 时启用;默认行为保持不变。
from ..twelvelabs_provider import TwelveLabsVisionProvider
LLMServiceManager.register_vision_provider('twelvelabs', TwelveLabsVisionProvider)
logger.info("✅ TwelveLabs Pegasus 视觉提供商注册完成")
# 导出注册函数 # 导出注册函数
__all__ = [ __all__ = [

View File

@ -6,10 +6,17 @@ from unittest.mock import patch
from app.config import config from app.config import config
from app.services.llm.base import TextModelProvider from app.services.llm.base import TextModelProvider
from app.services.llm.exceptions import ConfigurationError
from app.services.llm.manager import LLMServiceManager from app.services.llm.manager import LLMServiceManager
from app.services.llm.migration_adapter import LegacyLLMAdapter, VisionAnalyzerAdapter from app.services.llm.migration_adapter import LegacyLLMAdapter, VisionAnalyzerAdapter
from app.services.llm.openai_compatible_provider import OpenAICompatibleTextProvider, OpenAICompatibleVisionProvider from app.services.llm.openai_compatible_provider import (
OpenAICompatibleTextProvider,
OpenAICompatibleVisionProvider,
is_trusted_openai_compatible_base_url,
validate_openai_compatible_base_url,
)
from app.services.llm.providers import register_all_providers from app.services.llm.providers import register_all_providers
from app.utils.openai_base_url_security import openai_compatible_base_url_warning
class DummyOpenAITextProvider(TextModelProvider): class DummyOpenAITextProvider(TextModelProvider):
@ -45,11 +52,12 @@ class OpenAICompatManagerTests(unittest.TestCase):
config.app.clear() config.app.clear()
config.app.update(self._original_app) config.app.update(self._original_app)
def test_register_all_providers_only_registers_openai_provider(self): def test_register_all_providers_registers_expected_providers(self):
register_all_providers() register_all_providers()
# 文本仅 OpenAI 兼容;视觉额外提供可选的 TwelveLabs Pegasus。
self.assertEqual({"openai"}, set(LLMServiceManager.list_text_providers())) self.assertEqual({"openai"}, set(LLMServiceManager.list_text_providers()))
self.assertEqual({"openai"}, set(LLMServiceManager.list_vision_providers())) self.assertEqual({"openai", "twelvelabs"}, set(LLMServiceManager.list_vision_providers()))
def test_get_text_provider_uses_openai_keys(self): def test_get_text_provider_uses_openai_keys(self):
LLMServiceManager.register_text_provider("openai", DummyOpenAITextProvider) LLMServiceManager.register_text_provider("openai", DummyOpenAITextProvider)
@ -141,6 +149,20 @@ class OpenAICompatGenerationOptionTests(unittest.TestCase):
self.assertEqual(65536, options["max_tokens"]) self.assertEqual(65536, options["max_tokens"])
self.assertNotIn("extra_body", options) 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): def test_build_options_uses_per_model_generation_config(self):
provider = OpenAICompatibleTextProvider(api_key="k", model_name="m") provider = OpenAICompatibleTextProvider(api_key="k", model_name="m")
config.app.update( config.app.update(
@ -169,6 +191,77 @@ class OpenAICompatGenerationOptionTests(unittest.TestCase):
self.assertEqual(512, options["max_tokens"]) self.assertEqual(512, options["max_tokens"])
class OpenAICompatBaseURLValidationTests(unittest.TestCase):
def setUp(self):
self._original_app = dict(config.app)
def tearDown(self):
config.app.clear()
config.app.update(self._original_app)
def test_known_providers_and_local_ollama_are_trusted(self):
trusted_urls = [
"https://api.openai.com/v1",
"https://api.siliconflow.cn/v1",
"https://openrouter.ai/api/v1",
"https://dashscope.aliyuncs.com/compatible-mode/v1",
"https://example.openai.azure.com/openai/deployments/demo",
"http://localhost:11434/v1",
"http://127.0.0.1:11434/v1",
]
for url in trusted_urls:
with self.subTest(url=url):
self.assertTrue(is_trusted_openai_compatible_base_url(url))
def test_unrecognized_or_unsafe_base_urls_are_not_trusted(self):
untrusted_urls = [
"https://attacker.example/v1",
"http://api.openai.com/v1",
"https://user@api.openai.com/v1",
"https://127.0.0.1:9999/v1",
"not-a-url",
]
for url in untrusted_urls:
with self.subTest(url=url):
self.assertFalse(is_trusted_openai_compatible_base_url(url))
def test_build_client_allows_well_formed_custom_base_url_by_default(self):
provider = OpenAICompatibleTextProvider(
api_key="test-key",
model_name="test-model",
base_url="https://custom.example/v1",
)
with patch("app.services.llm.openai_compatible_provider.AsyncOpenAI") as async_openai:
provider._build_client()
self.assertEqual("https://custom.example/v1", async_openai.call_args.kwargs["base_url"])
def test_custom_base_url_validation_returns_normalized_url(self):
self.assertEqual(
"https://custom.example/v1",
validate_openai_compatible_base_url(" https://custom.example/v1 "),
)
def test_custom_base_url_warning_only_for_untrusted_well_formed_urls(self):
warning = openai_compatible_base_url_warning("https://custom.example/v1")
self.assertIn("custom.example", warning)
self.assertEqual("", openai_compatible_base_url_warning("https://api.openai.com/v1"))
self.assertEqual("", openai_compatible_base_url_warning(""))
def test_custom_base_url_validation_rejects_malformed_urls(self):
provider = OpenAICompatibleTextProvider(
api_key="test-key",
model_name="test-model",
base_url="https://user@custom.example/v1",
)
with self.assertRaises(ConfigurationError):
provider._build_client()
class ExplicitVisionAdapterSettingsTests(unittest.IsolatedAsyncioTestCase): class ExplicitVisionAdapterSettingsTests(unittest.IsolatedAsyncioTestCase):
class _CapturingVisionProvider: class _CapturingVisionProvider:
last_init: tuple[str, str, str | None] | None = None last_init: tuple[str, str, str | None] | None = None

View File

@ -24,11 +24,14 @@ class SubtitleAnalyzerAdapterPipelineTests(unittest.TestCase):
temperature=0.7, temperature=0.7,
narration_language="简体中文(中国)", narration_language="简体中文(中国)",
drama_genre="家庭伦理", drama_genre="家庭伦理",
narration_word_count=800,
) )
self.assertEqual("success", result["status"]) self.assertEqual("success", result["status"])
self.assertIn("反击", result["narration_copy"]) self.assertIn("反击", result["narration_copy"])
self.assertIn("家庭伦理", call.call_args.kwargs["prompt"]) 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) self.assertNotIn("response_format", call.call_args.kwargs)
def test_generate_narration_copy_can_use_film_tv_prompt_category(self): def test_generate_narration_copy_can_use_film_tv_prompt_category(self):
@ -49,11 +52,14 @@ class SubtitleAnalyzerAdapterPipelineTests(unittest.TestCase):
temperature=0.7, temperature=0.7,
narration_language="简体中文(中国)", narration_language="简体中文(中国)",
drama_genre="悬疑/犯罪", drama_genre="悬疑/犯罪",
narration_word_count=1200,
) )
self.assertEqual("success", result["status"]) self.assertEqual("success", result["status"])
self.assertIn("影视解说正文创作任务", call.call_args.kwargs["prompt"]) self.assertIn("影视解说正文创作任务", call.call_args.kwargs["prompt"])
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"]) self.assertNotIn("短剧解说正文创作任务", call.call_args.kwargs["prompt"])
def test_film_tv_script_prompts_exclude_intro_outro_and_ads(self): def test_film_tv_script_prompts_exclude_intro_outro_and_ads(self):

View File

@ -0,0 +1,91 @@
"""TwelveLabs Pegasus 视觉 provider 的最小回归测试。
- 无网络单元测试mock SDK ffmpeg校验 provider 把关键帧批次转成 Pegasus 文本
并正确执行 max_tokens 下限批次降级与 Asset 清理
- 可选 live 测试仅在设置 TWELVELABS_API_KEY 时运行验证真实 SDK 契约
"""
import asyncio
import os
import unittest
from unittest.mock import MagicMock, patch
import PIL.Image
from app.config import config
from app.services.llm.manager import LLMServiceManager
from app.services.llm.providers import register_all_providers
from app.services.llm.twelvelabs_provider import TwelveLabsVisionProvider
def _make_provider() -> TwelveLabsVisionProvider:
# _resolve_ffmpeg 在 _initialize 中执行patch shutil.which 让其在无 ffmpeg 环境也可构建。
with patch("app.services.llm.twelvelabs_provider.shutil.which", return_value="/usr/bin/ffmpeg"):
return TwelveLabsVisionProvider(api_key="test-key", model_name="pegasus1.5")
class TwelveLabsProviderUnitTests(unittest.TestCase):
def test_registered_as_vision_provider(self):
LLMServiceManager._vision_providers.clear()
LLMServiceManager._text_providers.clear()
register_all_providers()
self.assertIn("twelvelabs", LLMServiceManager.list_vision_providers())
def test_resolve_max_tokens_enforces_floor(self):
provider = _make_provider()
# 低于 Pegasus 下限 512 时被抬到 512。
self.assertEqual(512, provider._resolve_max_tokens(10))
self.assertEqual(2048, provider._resolve_max_tokens(2048))
def test_analyze_images_returns_pegasus_text(self):
provider = _make_provider()
# 伪造 SDK client上传 -> ready -> analyze 返回文本。
fake_client = MagicMock()
fake_client.assets.create.return_value = MagicMock(id="asset-1")
fake_client.assets.retrieve.return_value = MagicMock(status="ready")
fake_client.analyze.return_value = MagicMock(data="A red frame fades to blue.", finish_reason="stop")
img = PIL.Image.new("RGB", (64, 64), (200, 30, 30))
with patch.object(provider, "_build_client", return_value=fake_client), \
patch.object(provider, "_frames_to_clip", return_value="/tmp/clip.mp4"), \
patch("app.services.llm.twelvelabs_provider.os.path.getsize", return_value=1234), \
patch("builtins.open", MagicMock()):
results = asyncio.run(provider.analyze_images(images=[img, img], prompt="describe", batch_size=10))
self.assertEqual(["A red frame fades to blue."], results)
fake_client.analyze.assert_called_once()
# 调用使用配置的模型与 >=512 的 max_tokens。
_, kwargs = fake_client.analyze.call_args
self.assertEqual("pegasus1.5", kwargs["model_name"])
self.assertGreaterEqual(kwargs["max_tokens"], 512)
# 远端 Asset 被清理。
fake_client.assets.delete.assert_called_once_with(asset_id="asset-1")
def test_analyze_images_degrades_on_batch_error(self):
provider = _make_provider()
with patch.object(provider, "_analyze_batch_sync", side_effect=RuntimeError("boom")):
img = PIL.Image.new("RGB", (64, 64), (0, 0, 0))
results = asyncio.run(provider.analyze_images(images=[img], prompt="p", batch_size=10))
self.assertEqual(1, len(results))
self.assertIn("批次处理失败", results[0])
class TwelveLabsProviderLiveTests(unittest.TestCase):
"""需要真实 API Key 与 ffmpeg未配置时跳过。"""
@unittest.skipUnless(os.getenv("TWELVELABS_API_KEY"), "TWELVELABS_API_KEY 未设置,跳过 live 测试")
def test_live_keyframe_analysis_returns_text(self):
provider = TwelveLabsVisionProvider(
api_key=os.environ["TWELVELABS_API_KEY"], model_name="pegasus1.5"
)
frames = [PIL.Image.new("RGB", (640, 360), c) for c in [(220, 40, 40), (40, 180, 60), (40, 80, 220)]]
results = asyncio.run(provider.analyze_images(images=frames, prompt="Describe what is shown.", batch_size=10))
self.assertEqual(1, len(results))
self.assertTrue(results[0].strip())
self.assertNotIn("批次处理失败", results[0])
if __name__ == "__main__":
unittest.main()

View File

@ -0,0 +1,253 @@
"""
TwelveLabs 视觉模型提供商实现
使用 TwelveLabs 官方 Python SDK 调用 Pegasus 视频理解模型
与其它视觉提供商OpenAI 兼容接口不同Pegasus 是一个原生的*视频*理解模型
而非逐帧图像模型为了在不改动现有调用方关键帧批次 -> 文本描述的前提下接入
本提供商把每个关键帧批次用 ffmpeg 组装成一段短视频片段上传为 TwelveLabs Asset
再调用 Pegasus 进行分析返回与其它视觉提供商一致的文本结果
这是一个**可选**的视觉提供商仅当 `vision_llm_provider = "twelvelabs"` 时才会启用
默认行为保持不变未配置 TwelveLabs API Key 整套流程与之前完全一致
"""
import asyncio
import os
import shutil
import subprocess
import tempfile
from pathlib import Path
from typing import Any, Dict, List, Optional, Union
import PIL.Image
from loguru import logger
from app.config import config
from .base import VisionModelProvider
from .exceptions import APICallError, AuthenticationError, ConfigurationError, RateLimitError
# Pegasus 对分析窗口的硬性要求:最短 4 秒。
_MIN_CLIP_SECONDS = 4
# Pegasus 1.5 同步分析对 max_tokens 的有效区间为 [512, 98304]。
_MIN_MAX_TOKENS = 512
# 本地直传 Asset 的体积上限method="direct",约 200MB。关键帧拼接的短片远小于此值。
_DIRECT_UPLOAD_LIMIT_BYTES = 200 * 1024 * 1024
class TwelveLabsVisionProvider(VisionModelProvider):
"""TwelveLabs Pegasus 视频理解提供商。"""
@property
def provider_name(self) -> str:
return "twelvelabs"
@property
def supported_models(self) -> List[str]:
return ["pegasus1.5", "pegasus1.2"]
def _validate_model_support(self):
# Pegasus 模型列表稳定,保持宽松校验(与基类一致,仅记录警告)。
if self.model_name not in self.supported_models:
logger.warning(
f"模型 {self.model_name} 不在 TwelveLabs 预定义列表中,"
f"将按原样传递给 API。支持的模型: {self.supported_models}"
)
def _initialize(self):
# SDK client 按请求构建,这里仅校验 ffmpeg 可用性(拼接关键帧片段需要)。
self._ffmpeg_bin = self._resolve_ffmpeg()
@staticmethod
def _resolve_ffmpeg() -> str:
configured = (config.app.get("ffmpeg_path") or "").strip()
if configured:
return configured
found = shutil.which("ffmpeg")
if not found:
raise ConfigurationError(
"TwelveLabs 提供商需要 ffmpeg 将关键帧拼接为视频片段,但未找到 ffmpeg。"
"请安装 ffmpeg 或在配置中设置 ffmpeg_path。",
"ffmpeg_path",
)
return found
def _build_client(self):
try:
from twelvelabs import TwelveLabs
except ImportError as exc: # pragma: no cover - 仅在缺少可选依赖时触发
raise ConfigurationError(
"未安装 twelvelabs SDK。请运行 `pip install twelvelabs>=1.2.8` 后重试。",
"twelvelabs",
) from exc
return TwelveLabs(api_key=self.api_key)
async def analyze_images(
self,
images: List[Union[str, Path, PIL.Image.Image]],
prompt: str,
batch_size: int = 10,
max_concurrency: int = 1,
**kwargs,
) -> List[str]:
logger.info(
f"开始使用 TwelveLabs Pegasus ({self.model_name}) 分析 {len(images)} 张关键帧"
)
processed_images = self._prepare_images(images)
if not processed_images:
return []
bounded_concurrency = max(1, int(max_concurrency))
semaphore = asyncio.Semaphore(bounded_concurrency)
batches = [
(index // batch_size, processed_images[index : index + batch_size])
for index in range(0, len(processed_images), batch_size)
]
max_tokens = self._resolve_max_tokens(kwargs.get("max_tokens"))
async def run_batch(batch_index: int, batch: List[PIL.Image.Image]) -> tuple[int, str]:
logger.info(f"处理第 {batch_index + 1} 批,共 {len(batch)} 张关键帧")
async with semaphore:
try:
# SDK 为同步实现,放到线程池中执行以免阻塞事件循环。
result = await asyncio.to_thread(
self._analyze_batch_sync, batch, prompt, max_tokens
)
return batch_index, result
except Exception as exc: # 与其它 provider 保持一致:批次级降级,不整体失败。
logger.error(f"批次 {batch_index + 1} 处理失败: {exc}")
return batch_index, f"批次处理失败: {exc}"
completed = await asyncio.gather(
*(run_batch(index, batch) for index, batch in batches)
)
completed.sort(key=lambda item: item[0])
return [result for _, result in completed]
def _resolve_max_tokens(self, override: Any) -> int:
configured = override if override is not None else config.app.get(
"vision_twelvelabs_max_tokens", 1024
)
try:
value = int(configured)
except (TypeError, ValueError):
value = 1024
return max(_MIN_MAX_TOKENS, value)
def _analyze_batch_sync(
self, batch: List[PIL.Image.Image], prompt: str, max_tokens: int
) -> str:
"""把一批关键帧拼成短视频,上传为 Asset调用 Pegasus 分析后返回文本。"""
from twelvelabs.types.video_context import VideoContext_AssetId
from twelvelabs.errors import (
BadRequestError,
ForbiddenError,
TooManyRequestsError,
)
client = self._build_client()
asset_id: Optional[str] = None
with tempfile.TemporaryDirectory(prefix="tl_pegasus_") as tmp_dir:
clip_path = self._frames_to_clip(batch, tmp_dir)
size = os.path.getsize(clip_path)
if size > _DIRECT_UPLOAD_LIMIT_BYTES:
raise APICallError(
f"拼接片段过大({size} 字节),超过直传上限 {_DIRECT_UPLOAD_LIMIT_BYTES} 字节。"
"请减小 vision_batch_size 或降低关键帧分辨率。"
)
try:
with open(clip_path, "rb") as fh:
asset = client.assets.create(
method="direct", file=fh, filename="keyframes.mp4"
)
asset_id = asset.id
self._wait_for_asset_ready(client, asset_id)
response = client.analyze(
model_name=self.model_name,
video=VideoContext_AssetId(asset_id=asset_id),
prompt=prompt,
max_tokens=max_tokens,
)
text = (response.data or "").strip()
if not text:
raise APICallError("TwelveLabs Pegasus 返回空响应")
return text
except ForbiddenError as exc:
raise AuthenticationError(str(exc))
except TooManyRequestsError as exc:
raise RateLimitError(str(exc))
except BadRequestError as exc:
raise APICallError(f"请求错误: {getattr(exc, 'body', exc)}")
finally:
# 尽力清理远端 Asset避免占用配额。
if asset_id:
try:
client.assets.delete(asset_id=asset_id)
except Exception as exc: # pragma: no cover - 清理失败不影响结果
logger.debug(f"清理 TwelveLabs Asset 失败 {asset_id}: {exc}")
def _frames_to_clip(self, batch: List[PIL.Image.Image], tmp_dir: str) -> str:
"""用 ffmpeg 把关键帧序列拼成 >= 4s 的视频片段(满足 Pegasus 最短窗口要求)。"""
frame_paths: List[str] = []
for idx, img in enumerate(batch):
frame_path = os.path.join(tmp_dir, f"frame_{idx:04d}.jpg")
img.convert("RGB").save(frame_path, format="JPEG", quality=85)
frame_paths.append(frame_path)
# 每帧停留时长,保证总时长不少于 _MIN_CLIP_SECONDS。
per_frame_seconds = max(1.0, _MIN_CLIP_SECONDS / max(1, len(frame_paths)))
list_file = os.path.join(tmp_dir, "frames.txt")
with open(list_file, "w", encoding="utf-8") as fh:
for frame_path in frame_paths:
fh.write(f"file '{frame_path}'\n")
fh.write(f"duration {per_frame_seconds}\n")
# concat demuxer 需要重复最后一帧才能让其显示完整时长。
fh.write(f"file '{frame_paths[-1]}'\n")
clip_path = os.path.join(tmp_dir, "clip.mp4")
cmd = [
self._ffmpeg_bin,
"-y",
"-loglevel", "error",
"-f", "concat",
"-safe", "0",
"-i", list_file,
# 强制偶数尺寸 + yuv420p保证 H.264 兼容。
"-vf", "scale=trunc(iw/2)*2:trunc(ih/2)*2",
"-pix_fmt", "yuv420p",
"-r", "24",
"-c:v", "libx264",
clip_path,
]
try:
subprocess.run(cmd, capture_output=True, text=True, check=True, timeout=120)
except subprocess.CalledProcessError as exc:
raise APICallError(f"ffmpeg 拼接关键帧失败: {exc.stderr or exc}")
except subprocess.TimeoutExpired:
raise APICallError("ffmpeg 拼接关键帧超时")
return clip_path
@staticmethod
def _wait_for_asset_ready(client, asset_id: str, timeout_seconds: int = 180) -> None:
"""轮询等待上传的 Asset 进入 ready 状态。"""
import time
deadline = time.monotonic() + timeout_seconds
while time.monotonic() < deadline:
asset = client.assets.retrieve(asset_id=asset_id)
status = (asset.status or "").lower()
if status == "ready":
return
if status == "failed":
raise APICallError(f"TwelveLabs Asset 处理失败: {asset_id}")
time.sleep(3)
raise APICallError(f"等待 TwelveLabs Asset 就绪超时: {asset_id}")
async def _make_api_call(self, payload: Dict[str, Any]) -> Dict[str, Any]:
# 本提供商直接使用官方 SDK不走通用 payload 通道。
return payload

View File

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

View File

@ -11,6 +11,7 @@
from .subtitle_analysis import SubtitleAnalysisPrompt from .subtitle_analysis import SubtitleAnalysisPrompt
from .plot_extraction import PlotExtractionPrompt from .plot_extraction import PlotExtractionPrompt
from .script_generation import ScriptGenerationPrompt
from ..manager import PromptManager from ..manager import PromptManager
@ -25,9 +26,14 @@ def register_prompts():
plot_extraction_prompt = PlotExtractionPrompt() plot_extraction_prompt = PlotExtractionPrompt()
PromptManager.register_prompt(plot_extraction_prompt, is_default=True) PromptManager.register_prompt(plot_extraction_prompt, is_default=True)
# 注册混剪脚本生成提示词
script_generation_prompt = ScriptGenerationPrompt()
PromptManager.register_prompt(script_generation_prompt, is_default=True)
__all__ = [ __all__ = [
"SubtitleAnalysisPrompt", "SubtitleAnalysisPrompt",
"PlotExtractionPrompt", "PlotExtractionPrompt",
"ScriptGenerationPrompt",
"register_prompts" "register_prompts"
] ]

View File

@ -0,0 +1,113 @@
#!/usr/bin/env python
# -*- coding: UTF-8 -*-
"""
@Project: 短剧混剪-剪辑脚本生成
@File : script_generation.py
@Description: 基于剧情理解和字幕直接生成短剧混剪脚本
"""
from ..base import ParameterizedPrompt, PromptMetadata, ModelType, OutputFormat
class ScriptGenerationPrompt(ParameterizedPrompt):
"""短剧混剪脚本生成提示词"""
def __init__(self):
metadata = PromptMetadata(
name="script_generation",
category="short_drama_editing",
version="v1.0",
description="基于剧情理解和原始字幕直接生成短剧混剪脚本,不生成解说文案",
model_type=ModelType.TEXT,
output_format=OutputFormat.JSON,
tags=["短剧", "混剪", "剪辑脚本", "时间戳", "多视频", "原声"],
parameters=[
"drama_name",
"drama_genre",
"plot_analysis",
"subtitle_content",
"custom_clips",
],
)
super().__init__(
metadata,
required_parameters=["plot_analysis", "subtitle_content", "custom_clips"],
)
self._system_prompt = (
"你是一名专业短剧混剪剪辑师。你必须严格输出JSON"
"只从字幕中选择真实存在的可剪辑原声片段,不生成解说文案。"
)
def get_template(self) -> str:
return """# 短剧混剪脚本生成任务
## 目标
根据剧情理解和原始字幕为短剧${drama_name}生成一份可直接裁剪的混剪 JSON 脚本
短剧混剪与短剧解说的区别
- 不生成解说文案
- 不需要用户审核旁白
- 直接从剧情理解中选择能串成故事线的原片片段
- 每个片段默认保留原声OST 必须为 1
## 用户选择的短剧类型
<drama_genre>
${drama_genre}
</drama_genre>
## 需要生成的片段数量
<custom_clips>
${custom_clips}
</custom_clips>
## 剧情理解材料
<plot>
${plot_analysis}
</plot>
## 原始字幕(含视频编号和局部时间戳)
<subtitles>
${subtitle_content}
</subtitles>
## 选择原则
1. 选择 ${custom_clips} 个片段尽量形成开端 -> 冲突升级 -> 高潮/反转 -> 悬念或阶段结果的完整观看路径
2. 只能使用原始字幕中真实存在的视频编号视频文件名和时间范围
3. timestamp 必须是对应 video_id 内部的局部时间戳格式为 "HH:MM:SS,mmm-HH:MM:SS,mmm"
4. 同一个 video_id 内的片段不得交叉或重叠整体顺序要服务剧情理解单个视频内尽量按时间顺序
5. 优先选择关键对白身份揭露情绪爆发反转冲突升级和能看懂前因后果的片段
6. 单个片段建议 5-45 不要只截 1-2 秒的孤立金句也不要截过长的流水账
7. 如果两个关键剧情之间跳跃太大优先选择包含上下文的连续时间段而不是硬切爆点
8. picture 要描述画面中人物动作情绪场景和该片段的剧情作用
9. narration 字段必须写成播放原片+_id例如 _id 3 时写播放原片3
10. OST 必须为 1表示保留原片原声
## 字段规则
- _id 1 开始连续递增
- video_id来自字幕分段标题例如视频 2就填 2单视频填 1
- video_name对应视频文件名必须从字幕分段标题提取单视频也要填写
- timestamp必须来自对应视频字幕时间轴
- picture非空字符串
- narration固定为播放原片+_id
- OST固定为 1
## 输出格式
只输出严格 JSON
{
"items": [
{
"_id": 1,
"video_id": 1,
"video_name": "1.mp4",
"timestamp": "00:00:01,000-00:00:12,500",
"picture": "女主被当众羞辱仍然强撑,冲突正式爆发,为后续逆袭埋下情绪钩子",
"narration": "播放原片1",
"OST": 1
}
]
}
现在请生成短剧混剪脚本"""

View File

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

626
app/services/sonilo.py Normal file
View File

@ -0,0 +1,626 @@
"""
Sonilo (https://sonilo.com) AI 配乐BGM AI 音效SFX集成
均为可选功能默认关闭
配乐BGM将合成完成的视频未加 BGM上传到 Sonilo API
`POST /v1/video-to-music`根据画面内容与剪辑节奏生成一段背景音乐
作为普通音频文件交还给现有的合成流程混音生成的音乐已获授权
可商用以条款为准
音效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` 兜底
* 配乐模块只负责生成音频文件音量淡出循环与混音全部复用
app/services/generate_video.py 中现有的音频处理逻辑解说配音的
音量压制策略保持不变
* 任何失败超时HTTP 错误流中断时长超限混音失败都只记录
日志并返回空字符串由调用方回退到现有逻辑绝不中断成片任务
* 上传属于计费操作上传前先用 ffprobe 在本地校验视频时长配乐接口
目前拒绝超过 6 分钟的视频音效接口拒绝超过 3 分钟的视频避免
白传一次注定被拒绝的成片
配乐接口返回 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_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
import binascii
import json
import os
import subprocess
import time
from typing import Iterable, Optional
import requests
from loguru import logger
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
# 轮询任务状态是免费且幂等的 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):
"""Sonilo 配乐生成失败。"""
def get_api_key() -> str:
"""返回配置的 Sonilo API Key优先 config.toml其次环境变量"""
api_key = config.app.get("sonilo_api_key", "") or os.getenv("SONILO_API_KEY", "")
return str(api_key).strip()
def is_enabled() -> bool:
"""仅当配置了 Sonilo API Key 时返回 True。"""
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生成配乐并保存到
`save_path`.m4a
成功时返回音频文件路径任何失败都返回空字符串由调用方回退到
现有的 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_VIDEO_DURATION_SECONDS:
logger.warning(
f"Sonilo 配乐已跳过: 视频时长 {duration:.1f}s 超过接口上限 "
f"{MAX_VIDEO_DURATION_SECONDS}s"
)
return ""
try:
audio = _request_video_to_music(video_path)
except Exception as e:
# 任何失败超时、HTTP 错误、流中断)都降级,由调用方回退到
# 现有 BGM 逻辑,绝不让配乐问题中断成片任务。
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 _get_ffprobe_binary() -> str:
"""与 generate_video 保持一致的 ffprobe 查找逻辑(环境变量优先)。"""
for env_name in ("NARRATO_FFPROBE_EXE", "IMAGEIO_FFPROBE_EXE"):
candidate = os.environ.get(env_name, "").strip()
if candidate and os.path.isfile(candidate):
return candidate
return "ffprobe"
def _probe_video_duration(video_path: str) -> float:
"""
尽力而为的本地 ffprobe 时长探测ffprobe 不可用超时或输出无法解析
时返回 0.0交给后端做最终校验
"""
try:
result = subprocess.run(
[
_get_ffprobe_binary(),
"-v",
"quiet",
"-print_format",
"json",
"-show_format",
video_path,
],
capture_output=True,
timeout=30,
)
except (OSError, subprocess.TimeoutExpired):
return 0.0
if result.returncode != 0:
return 0.0
try:
return float(json.loads(result.stdout)["format"]["duration"])
except (json.JSONDecodeError, KeyError, TypeError, ValueError):
return 0.0
def _error_detail(body: str) -> str:
try:
parsed = json.loads(body)
except (json.JSONDecodeError, TypeError):
return body
if isinstance(parsed, dict):
detail = parsed.get("detail") or parsed.get("error") or parsed.get("message")
if isinstance(detail, str) and detail.strip():
return detail.strip()
return body
def _http_error_message(status_code: int, body: str) -> str:
detail = _error_detail(body)
if status_code == 401:
return "Sonilo API Key 无效,请检查配置"
if status_code == 402:
return detail or "Sonilo 账户余额不足"
if status_code == 413:
return f"视频文件过大: {detail}"
if status_code == 429:
return f"触发 Sonilo 频率限制: {detail}"
return f"Sonilo 接口错误 ({status_code}): {detail}"
def _get_timeout_seconds() -> float:
try:
return float(
config.app.get("sonilo_timeout_seconds", DEFAULT_TIMEOUT_SECONDS)
)
except (TypeError, ValueError):
return DEFAULT_TIMEOUT_SECONDS
def _request_video_to_music(video_path: str) -> bytes:
base_url = _get_base_url()
timeout_seconds = _get_timeout_seconds()
prompt = str(config.app.get("sonilo_bgm_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}, "
f"读超时: {timeout_seconds:.0f}s"
)
try:
with open(video_path, "rb") as video_file:
files = {
"video": (os.path.basename(video_path), video_file, "video/mp4"),
}
# 生成接口非幂等(生成即计费),失败不做自动重试,直接降级。
with requests.post(
f"{base_url}{VIDEO_TO_MUSIC_PATH}",
headers=headers,
data=data,
files=files,
stream=True,
timeout=(_CONNECT_TIMEOUT_SECONDS, timeout_seconds),
) as response:
if response.status_code >= 400:
body = response.content.decode("utf-8", errors="replace")
raise SoniloError(
_http_error_message(response.status_code, body)
)
return _consume_ndjson_stream(
response.iter_lines(decode_unicode=True)
)
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
def _consume_ndjson_stream(lines: Iterable[str]) -> bytes:
"""
消费 NDJSON 事件流 stream_index 分组 base64 音频分片
返回第一条音轨
"""
streams = {}
completed = False
for line in lines:
if not line or not line.strip():
continue
try:
event = json.loads(line)
except json.JSONDecodeError:
continue
if not isinstance(event, dict):
continue
event_type = event.get("type")
if event_type == "audio_chunk":
chunk = event.get("data")
if not isinstance(chunk, str):
continue
try:
index = int(event.get("stream_index", 0))
except (TypeError, ValueError):
continue
if index < 0:
continue
try:
decoded = base64.b64decode(chunk, validate=True)
except (binascii.Error, ValueError):
continue
streams.setdefault(index, bytearray()).extend(decoded)
elif event_type == "complete":
completed = True
elif event_type == "error":
message = event.get("message") or event.get("code") or "stream error"
raise SoniloError(f"Sonilo 生成失败: {message}")
# title / stage_start 等进度事件一律忽略。
if not completed:
raise SoniloError("Sonilo 事件流意外终止(未收到 complete 事件)")
if not streams:
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 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.manager import LLMServiceManager
from app.services.llm.migration_adapter import _run_async_safely from app.services.llm.migration_adapter import _run_async_safely
from app.services.llm.unified_service import UnifiedLLMService from app.services.llm.unified_service import UnifiedLLMService
@ -174,12 +176,16 @@ def correct_srt_content(
provider: str = "", provider: str = "",
api_key: str = "", api_key: str = "",
base_url: str = "", base_url: str = "",
model_name: str = "",
temperature: float = 0.1, temperature: float = 0.1,
) -> str: ) -> str:
blocks = parse_srt_blocks(srt_content) blocks = parse_srt_blocks(srt_content)
_ensure_llm_providers_registered() _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) prompt = _build_correction_prompt(blocks)
raw_output = _run_async_safely( raw_output = _run_async_safely(
UnifiedLLMService.generate_text, UnifiedLLMService.generate_text,
@ -190,6 +196,8 @@ def correct_srt_content(
response_format="json", response_format="json",
api_key=api_key, api_key=api_key,
api_base=base_url, api_base=base_url,
model=resolved_model_name,
thinking_level="off",
) )
corrections = _parse_corrections(raw_output, {block.order for block in blocks}) corrections = _parse_corrections(raw_output, {block.order for block in blocks})
corrected_srt = _render_srt(blocks, corrections) corrected_srt = _render_srt(blocks, corrections)
@ -215,6 +223,7 @@ def correct_subtitle_file(
provider: str = "", provider: str = "",
api_key: str = "", api_key: str = "",
base_url: str = "", base_url: str = "",
model_name: str = "",
temperature: float = 0.1, temperature: float = 0.1,
) -> str: ) -> str:
if not subtitle_file or not os.path.isfile(subtitle_file): if not subtitle_file or not os.path.isfile(subtitle_file):
@ -226,6 +235,7 @@ def correct_subtitle_file(
provider=provider, provider=provider,
api_key=api_key, api_key=api_key,
base_url=base_url, base_url=base_url,
model_name=model_name,
temperature=temperature, temperature=temperature,
) )
return write_srt_file(corrected_srt, output_file) return write_srt_file(corrected_srt, output_file)

View File

@ -0,0 +1,383 @@
"""LLM-powered SRT subtitle translation."""
from __future__ import annotations
import json
import os
from concurrent.futures import ThreadPoolExecutor, as_completed
from math import ceil
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 (
_ensure_llm_providers_registered,
_extract_json_text,
parse_srt_blocks,
)
from app.services.subtitle_text import read_subtitle_text
from app.utils import utils
class SubtitleTranslationError(RuntimeError):
"""Raised when subtitle translation cannot produce a valid SRT."""
DEFAULT_BATCH_SIZE = 20
DEFAULT_MAX_WORKERS = 3
DEFAULT_MAX_REPAIR_ATTEMPTS = 3
TranslationProgressCallback = Callable[[int, int, str], None]
def _get_positive_int(value, default: int, *, minimum: int = 1, maximum: int | None = None) -> int:
try:
parsed = int(value)
except (TypeError, ValueError):
parsed = default
parsed = max(minimum, parsed)
if maximum is not None:
parsed = min(maximum, parsed)
return parsed
def _resolve_batch_size(batch_size: int | None = None) -> int:
if batch_size is None:
batch_size = config.app.get("subtitle_translate_batch_size", DEFAULT_BATCH_SIZE)
return _get_positive_int(batch_size, DEFAULT_BATCH_SIZE, minimum=1, maximum=200)
def _resolve_max_workers(max_workers: int | None = None) -> int:
if max_workers is None:
max_workers = config.app.get("subtitle_translate_max_workers", DEFAULT_MAX_WORKERS)
return _get_positive_int(max_workers, DEFAULT_MAX_WORKERS, minimum=1, maximum=8)
def _split_blocks(blocks, batch_size: int):
return [blocks[index:index + batch_size] for index in range(0, len(blocks), batch_size)]
def _build_translation_prompt(blocks, target_language: str) -> str:
payload = {str(block.order): block.text for block in blocks}
return f"""
请将以下 SRT 字幕文本翻译为{target_language}
翻译要求
1. 结合全部字幕内容理解语境输出自然准确适合字幕阅读的{target_language}
2. 只翻译字幕文本不要修改时间轴序号条目数量或条目顺序
3. 保留必要的说话人标记专有名词品牌名代码数字和换行除非目标语言中有约定译名
4. 不要添加解释注释剧情信息或 Markdown
5. 空字幕文本保持为空字符串
只输出严格 JSON 对象不要输出 Markdown 或解释文字必须保留所有输入 key格式必须为
{{"1":"翻译后的字幕文本","2":"翻译后的字幕文本"}}
待翻译字幕条目
{json.dumps(payload, ensure_ascii=False, indent=2)}
""".strip()
def _parse_translations(raw_output: str, expected_ids: set[int]) -> dict[int, str]:
json_text = _extract_json_text(raw_output)
try:
data: Any = json.loads(json_text)
except json.JSONDecodeError as exc:
raise SubtitleTranslationError("LLM 未返回有效 JSON 字幕翻译结果") from exc
if isinstance(data, dict) and "items" in data:
items = data["items"]
elif isinstance(data, list):
items = data
elif isinstance(data, dict):
items = [{"id": key, "text": value} for key, value in data.items()]
else:
raise SubtitleTranslationError("LLM 字幕翻译结果格式无效")
if not isinstance(items, list):
raise SubtitleTranslationError("LLM 字幕翻译结果缺少 items 列表")
translations: dict[int, str] = {}
for item in items:
if not isinstance(item, dict):
continue
try:
item_id = int(item.get("id"))
except (TypeError, ValueError):
continue
if item_id in expected_ids:
translations[item_id] = str(item.get("text") or "").strip()
missing_ids = sorted(expected_ids - set(translations.keys()))
if missing_ids:
raise SubtitleTranslationError(f"LLM 字幕翻译结果缺少字幕条目: {missing_ids[:10]}")
return translations
def _build_repair_prompt(
*,
blocks,
target_language: str,
previous_output: str,
error_message: str,
) -> str:
payload = {str(block.order): block.text for block in blocks}
return f"""
你上一轮返回的字幕翻译 JSON 无法通过校验请修复后重新输出
目标语言{target_language}
校验错误
{error_message}
原始字幕条目
{json.dumps(payload, ensure_ascii=False, indent=2)}
上一轮输出
{previous_output}
请只输出严格 JSON 对象必须包含并且只包含原始字幕条目的所有 key
""".strip()
def _translate_chunk(
*,
chunk,
chunk_index: int,
total_chunks: int,
target_language: str,
provider: str,
api_key: str,
base_url: str,
model_name: str,
temperature: float,
max_repair_attempts: int,
) -> dict[int, str]:
start_order = chunk[0].order
end_order = chunk[-1].order
expected_ids = {block.order for block in chunk}
logger.info(
f"字幕翻译批次 {chunk_index}/{total_chunks} 开始: "
f"条目 {start_order}-{end_order}, 共 {len(chunk)}"
)
prompt = _build_translation_prompt(chunk, target_language)
last_output = ""
last_error = ""
for attempt in range(1, max_repair_attempts + 1):
if attempt > 1:
logger.warning(
f"字幕翻译批次 {chunk_index}/{total_chunks}{attempt} 次修复: {last_error}"
)
prompt = _build_repair_prompt(
blocks=chunk,
target_language=target_language,
previous_output=last_output,
error_message=last_error,
)
raw_output = _run_async_safely(
UnifiedLLMService.generate_text,
prompt=prompt,
system_prompt=(
f"你是一位专业字幕翻译员,擅长在严格保留 JSON key 一一对应的前提下,"
f"将字幕准确翻译为{target_language}"
),
provider=provider,
temperature=temperature,
response_format="json",
api_key=api_key,
api_base=base_url,
model=model_name,
thinking_level="off",
)
last_output = str(raw_output or "")
try:
translations = _parse_translations(last_output, expected_ids)
except SubtitleTranslationError as exc:
last_error = str(exc)
if attempt >= max_repair_attempts:
logger.error(
f"字幕翻译批次 {chunk_index}/{total_chunks} 失败: "
f"条目 {start_order}-{end_order}, {last_error}"
)
raise
continue
logger.info(
f"字幕翻译批次 {chunk_index}/{total_chunks} 完成: "
f"条目 {start_order}-{end_order}"
)
return translations
raise SubtitleTranslationError(
f"字幕翻译批次 {chunk_index}/{total_chunks} 未生成有效结果: 条目 {start_order}-{end_order}"
)
def _call_progress_callback(
progress_callback: TranslationProgressCallback | None,
completed: int,
total: int,
message: str,
) -> None:
if not progress_callback:
return
try:
progress_callback(completed, total, message)
except Exception as exc:
logger.debug(f"字幕翻译进度回调失败: {exc}")
def _render_translated_srt(blocks, translations: dict[int, str]) -> str:
rendered_blocks = []
for block in blocks:
translated_text = translations.get(block.order, "")
rendered_blocks.append(f"{block.index_line}\n{block.time_line}\n{translated_text}")
return "\n\n".join(rendered_blocks).rstrip() + "\n"
def translate_srt_content(
srt_content: str,
*,
target_language: str = "中文",
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,
progress_callback: TranslationProgressCallback | None = None,
) -> str:
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)
resolved_max_workers = min(_resolve_max_workers(max_workers), len(chunks))
total_chunks = len(chunks)
total_blocks = len(blocks)
logger.info(
f"开始批量翻译字幕: 共 {total_blocks} 条, {total_chunks} 批, "
f"每批最多 {resolved_batch_size} 条, 并发 {resolved_max_workers}, "
f"目标语言: {target_language}, 高效率模型: {resolved_model_name}"
)
translations: dict[int, str] = {}
completed_blocks = 0
_call_progress_callback(
progress_callback,
0,
total_blocks,
f"开始翻译字幕,共 {total_blocks} 条,{total_chunks}",
)
if total_chunks == 1:
translations.update(
_translate_chunk(
chunk=chunks[0],
chunk_index=1,
total_chunks=total_chunks,
target_language=target_language,
provider=provider,
api_key=api_key,
base_url=base_url,
model_name=resolved_model_name,
temperature=temperature,
max_repair_attempts=DEFAULT_MAX_REPAIR_ATTEMPTS,
)
)
completed_blocks = total_blocks
_call_progress_callback(progress_callback, completed_blocks, total_blocks, "字幕翻译完成")
else:
with ThreadPoolExecutor(max_workers=resolved_max_workers) as executor:
future_to_meta = {}
for index, chunk in enumerate(chunks, start=1):
future = executor.submit(
_translate_chunk,
chunk=chunk,
chunk_index=index,
total_chunks=total_chunks,
target_language=target_language,
provider=provider,
api_key=api_key,
base_url=base_url,
model_name=resolved_model_name,
temperature=temperature,
max_repair_attempts=DEFAULT_MAX_REPAIR_ATTEMPTS,
)
future_to_meta[future] = (index, chunk)
for future in as_completed(future_to_meta):
chunk_index, chunk = future_to_meta[future]
chunk_translations = future.result()
translations.update(chunk_translations)
completed_blocks += len(chunk)
message = (
f"字幕翻译进度: {completed_blocks}/{total_blocks}"
f"({ceil(completed_blocks * 100 / total_blocks)}%), "
f"完成批次 {chunk_index}/{total_chunks}"
)
logger.info(message)
_call_progress_callback(progress_callback, completed_blocks, total_blocks, message)
missing_ids = sorted({block.order for block in blocks} - set(translations.keys()))
if missing_ids:
raise SubtitleTranslationError(f"字幕翻译结果缺少字幕条目: {missing_ids[:10]}")
translated_srt = _render_translated_srt(blocks, translations)
logger.info(f"字幕翻译完成,共 {total_blocks}")
return translated_srt
def write_srt_file(srt_content: str, subtitle_file: str = "") -> str:
if not subtitle_file:
subtitle_file = os.path.join(utils.subtitle_dir(), "subtitle_translated.srt")
parent = os.path.dirname(subtitle_file)
if parent:
os.makedirs(parent, exist_ok=True)
with open(subtitle_file, "w", encoding="utf-8") as f:
f.write(srt_content)
return subtitle_file
def translate_subtitle_file(
subtitle_file: str,
output_file: str = "",
*,
target_language: str = "中文",
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,
progress_callback: TranslationProgressCallback | None = None,
) -> str:
if not subtitle_file or not os.path.isfile(subtitle_file):
raise SubtitleTranslationError(f"字幕文件不存在: {subtitle_file}")
decoded = read_subtitle_text(subtitle_file)
translated_srt = translate_srt_content(
decoded.text,
target_language=target_language,
provider=provider,
api_key=api_key,
base_url=base_url,
model_name=model_name,
temperature=temperature,
batch_size=batch_size,
max_workers=max_workers,
progress_callback=progress_callback,
)
return write_srt_file(translated_srt, output_file)

View File

@ -19,6 +19,7 @@ from app.services import (
update_script, update_script,
generate_video, generate_video,
script_subtitle, script_subtitle,
sonilo,
) )
from app.services import state as sm from app.services import state as sm
from app.utils import utils from app.utils import utils
@ -214,6 +215,45 @@ def _build_subtitle_mask_options(params: VideoClipParams, enabled=None) -> dict:
} }
def _resolve_bgm_path(task_id: str, params: VideoClipParams, combined_video_path: str) -> str:
"""解析最终合成使用的背景音乐文件路径。
bgm_type "sonilo" 可选功能默认关闭将合并后的成片上传到
Sonilo API 生成配乐任何失败都只记录日志并回退到现有的随机背景音乐
逻辑绝不中断成片任务其余 bgm_type 走原有逻辑保持不变
"""
if getattr(params, "bgm_type", "") == "sonilo":
save_path = path.join(utils.task_dir(task_id), "sonilo_bgm.m4a")
bgm_path = sonilo.generate_bgm(combined_video_path, save_path)
if bgm_path:
return bgm_path
logger.warning("Sonilo 配乐不可用,回退到随机背景音乐")
return utils.get_bgm_file(bgm_type="random", bgm_file="")
return utils.get_bgm_file(
bgm_type=getattr(params, "bgm_type", "random"),
bgm_file=getattr(params, "bgm_file", ""),
)
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: def _transcribe_final_video(task_id: str, video_path: str, params: VideoClipParams) -> str:
"""Transcribe the fully merged video into an SRT file.""" """Transcribe the fully merged video into an SRT file."""
from app.services import fun_asr_subtitle from app.services import fun_asr_subtitle
@ -520,11 +560,11 @@ def start_subclip(task_id: str, params: VideoClipParams, subclip_path_videos: di
) )
logger.info(f"\n\n## 6. 最后一步: 合并字幕/BGM/配音/视频 -> {merge_output_video_path}") 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 = '/Users/apple/Desktop/home/NarratoAI/resource/songs/bgm.mp3'
bgm_path = utils.get_bgm_file( bgm_path = _resolve_bgm_path(task_id, params, combined_video_path)
bgm_type=getattr(params, "bgm_type", "random"),
bgm_file=getattr(params, "bgm_file", ""),
)
# 获取优化的音量配置 # 获取优化的音量配置
optimized_volumes = get_recommended_volumes_for_content('mixed') optimized_volumes = get_recommended_volumes_for_content('mixed')
@ -850,10 +890,10 @@ def start_subclip_unified(task_id: str, params: VideoClipParams):
ffmpeg_progress=0, ffmpeg_progress=0,
) )
bgm_path = utils.get_bgm_file( # 可选功能,默认关闭:混入 Sonilo AI 音效(失败时沿用原视频)
bgm_type=getattr(params, "bgm_type", "random"), combined_video_path = _apply_sonilo_sfx(task_id, params, combined_video_path)
bgm_file=getattr(params, "bgm_file", ""),
) bgm_path = _resolve_bgm_path(task_id, params, combined_video_path)
# 获取优化的音量配置 # 获取优化的音量配置
optimized_volumes = get_recommended_volumes_for_content('mixed') optimized_volumes = get_recommended_volumes_for_content('mixed')

View File

@ -0,0 +1,116 @@
import base64
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from app.services import voice
class FakeDoubaoResponse:
status_code = 200
text = "OK"
def json(self):
return {
"code": 3000,
"data": base64.b64encode(b"mp3-bytes").decode("ascii"),
}
class DoubaoTtsTests(unittest.TestCase):
def setUp(self):
self.original_doubaotts = dict(voice.config.doubaotts)
self.original_proxy = dict(voice.config.proxy)
def tearDown(self):
voice.config.doubaotts.clear()
voice.config.doubaotts.update(self.original_doubaotts)
voice.config.proxy.clear()
voice.config.proxy.update(self.original_proxy)
def test_api_key_auth_does_not_require_legacy_appid_or_token(self):
voice.config.doubaotts.clear()
voice.config.doubaotts.update(
{
"api_key": "db-api-key",
"cluster": "volcano_tts",
"volume": 1.2,
"pitch": 0.9,
"silence_duration": 0.25,
}
)
voice.config.proxy.clear()
voice.config.proxy.update({"enabled": False})
with tempfile.TemporaryDirectory() as temp_dir:
output_file = Path(temp_dir) / "doubao.mp3"
sub_maker = object()
with patch("requests.post", return_value=FakeDoubaoResponse()) as post, patch(
"app.services.voice.new_sub_maker", return_value=sub_maker
):
result = voice.doubaotts_tts(
text=" 你好,豆包新版鉴权。 ",
voice_name="BV700_V2_streaming",
voice_file=str(output_file),
speed=1.25,
)
output_bytes = output_file.read_bytes() if output_file.exists() else b""
self.assertIs(result, sub_maker)
self.assertEqual(output_bytes, b"mp3-bytes")
_, kwargs = post.call_args
self.assertEqual(kwargs["headers"]["X-Api-Key"], "db-api-key")
self.assertNotIn("Authorization", kwargs["headers"])
self.assertEqual(kwargs["json"]["app"], {"cluster": "volcano_tts"})
self.assertEqual(kwargs["json"]["request"]["text"], "你好,豆包新版鉴权。")
self.assertEqual(kwargs["json"]["audio"]["voice_type"], "BV700_V2_streaming")
self.assertEqual(kwargs["json"]["audio"]["speed_ratio"], 1.25)
self.assertEqual(kwargs["json"]["audio"]["volume_ratio"], 1.2)
self.assertEqual(kwargs["json"]["audio"]["pitch_ratio"], 0.9)
self.assertEqual(kwargs["json"]["audio"]["silence_duration"], 0.25)
def test_legacy_token_auth_still_sends_appid_and_token(self):
voice.config.doubaotts.clear()
voice.config.doubaotts.update(
{
"appid": "legacy-appid",
"token": "legacy-token",
"cluster": "volcano_tts",
}
)
voice.config.proxy.clear()
voice.config.proxy.update({"enabled": False})
with tempfile.TemporaryDirectory() as temp_dir:
output_file = Path(temp_dir) / "doubao.mp3"
with patch("requests.post", return_value=FakeDoubaoResponse()) as post:
result = voice.doubaotts_tts(
text="旧版鉴权仍然可用",
voice_name="BV700_streaming",
voice_file=str(output_file),
speed=1.0,
)
output_bytes = output_file.read_bytes()
self.assertIsNotNone(result)
self.assertEqual(output_bytes, b"mp3-bytes")
_, kwargs = post.call_args
self.assertEqual(kwargs["headers"]["Authorization"], "Bearer;legacy-token")
self.assertNotIn("X-Api-Key", kwargs["headers"])
self.assertEqual(
kwargs["json"]["app"],
{
"appid": "legacy-appid",
"token": "legacy-token",
"cluster": "volcano_tts",
},
)
if __name__ == "__main__":
unittest.main()

View File

@ -408,6 +408,69 @@ class LocalFunAsrServiceTests(unittest.TestCase):
self.assertEqual(123, session.calls[0][2]["timeout"]) self.assertEqual(123, session.calls[0][2]["timeout"])
self.assertIn("file", session.calls[0][2]["files"]) self.assertIn("file", session.calls[0][2]["files"])
def test_request_local_fun_asr_falls_back_to_openai_transcriptions_on_404(self):
class LocalSession:
def __init__(self):
self.calls = []
def post(self, url, **kwargs):
self.calls.append(("POST", url, kwargs))
if url.endswith("/asr"):
return FakeResponse({"detail": "Not Found"}, status_code=404)
return FakeResponse(
{
"text": "你好",
"segments": [{"start": 0.0, "end": 1.2, "text": "你好"}],
}
)
with tempfile.TemporaryDirectory() as tmp_dir:
local_file = Path(tmp_dir) / "audio.wav"
local_file.write_bytes(b"audio")
session = LocalSession()
result = fasr.request_local_fun_asr(
str(local_file),
api_url="http://127.0.0.1:7860",
enable_spk=True,
session=session,
)
self.assertEqual("你好", result["text"])
self.assertEqual("http://127.0.0.1:7860/asr", session.calls[0][1])
self.assertEqual("http://127.0.0.1:7860/v1/audio/transcriptions", session.calls[1][1])
self.assertEqual(
{"model": "sensevoice", "response_format": "verbose_json", "spk": "true"},
session.calls[1][2]["data"],
)
def test_request_local_fun_asr_prefers_explicit_openai_base_url(self):
class LocalSession:
def __init__(self):
self.calls = []
def post(self, url, **kwargs):
self.calls.append(("POST", url, kwargs))
return FakeResponse({"text": "你好"})
with tempfile.TemporaryDirectory() as tmp_dir:
local_file = Path(tmp_dir) / "audio.wav"
local_file.write_bytes(b"audio")
session = LocalSession()
fasr.request_local_fun_asr(
str(local_file),
api_url="http://127.0.0.1:8000/v1",
session=session,
)
self.assertEqual(1, len(session.calls))
self.assertEqual("http://127.0.0.1:8000/v1/audio/transcriptions", session.calls[0][1])
self.assertEqual(
{"model": "sensevoice", "response_format": "verbose_json"},
session.calls[0][2]["data"],
)
def test_create_with_local_fun_asr_copies_pack_srt_file(self): def test_create_with_local_fun_asr_copies_pack_srt_file(self):
class LocalSession: class LocalSession:
def __init__(self, srt_file): def __init__(self, srt_file):
@ -480,6 +543,20 @@ class LocalFunAsrServiceTests(unittest.TestCase):
self.assertIn("00:00:00,000 --> 00:00:00,600\n你好,", srt) self.assertIn("00:00:00,000 --> 00:00:00,600\n你好,", srt)
self.assertIn("世界。", srt) self.assertIn("世界。", srt)
def test_local_fun_asr_result_to_srt_uses_openai_segments(self):
result = {
"text": "你好世界",
"segments": [
{"start": 1.2, "end": 2.4, "text": "你好"},
{"start": 2.4, "end": 3.6, "text": "世界"},
],
}
srt = fasr.local_fun_asr_result_to_srt(result, max_chars=20)
self.assertIn("00:00:01,200 --> 00:00:02,400\n你好", srt)
self.assertIn("00:00:02,400 --> 00:00:03,600\n世界", srt)
class LocalFireRedAsrServiceTests(unittest.TestCase): class LocalFireRedAsrServiceTests(unittest.TestCase):
def test_request_local_firered_asr_posts_file_and_options(self): def test_request_local_firered_asr_posts_file_and_options(self):

View File

@ -0,0 +1,211 @@
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, content_type="application/json"):
self.status_code = status_code
self.content = content
self._payload = payload or {}
self.headers = {"content-type": content_type}
self.text = "OK"
def json(self):
return self._payload
class IndexTTS2TtsTests(unittest.TestCase):
def setUp(self):
self.original_indextts2 = dict(voice.config.indextts2)
self.original_proxy = dict(voice.config.proxy)
def tearDown(self):
voice.config.indextts2.clear()
voice.config.indextts2.update(self.original_indextts2)
voice.config.proxy.clear()
voice.config.proxy.update(self.original_proxy)
def test_uploads_reference_audio_and_downloads_pack_output_url(self):
voice.config.indextts2.clear()
voice.config.indextts2.update(
{
"api_url": "http://127.0.0.1:7860",
"emotion": "happy:0.7,calm:0.3",
"emo_alpha": 0.6,
"speed": 1.15,
"seed": 20260713,
"max_mel_tokens": 1500,
"max_text_tokens_per_segment": 120,
"interval_silence": 200,
"temperature": 0.8,
"top_p": 0.8,
"top_k": 30,
"repetition_penalty": 10.0,
"diffusion_steps": 25,
"cfg_rate": 0.7,
"segment_overlap_ms": 50,
}
)
voice.config.proxy.clear()
generation_response = FakeResponse(
payload={"output": {"url": "/outputs/audio/speech.wav"}}
)
download_response = FakeResponse(content=b"wav-bytes", content_type="audio/wav")
with tempfile.TemporaryDirectory() as temp_dir:
reference_audio = Path(temp_dir) / "reference.wav"
output_file = Path(temp_dir) / "output.wav"
reference_audio.write_bytes(b"reference-wav")
with (
patch("app.services.voice.requests.post", return_value=generation_response) as post,
patch("app.services.voice.requests.get", return_value=download_response) as get,
patch("app.services.voice.get_audio_duration_from_file", return_value=1.25),
):
result = voice.indextts2_tts(
text=" 新版接口测试。 ",
voice_name=f"indextts2:{reference_audio}",
voice_file=str(output_file),
)
output_bytes = output_file.read_bytes() if output_file.exists() else b""
self.assertIsNotNone(result)
self.assertEqual(output_bytes, b"wav-bytes")
self.assertEqual(
post.call_args.args[0],
"http://127.0.0.1:7860/v1/audio/speech/upload",
)
self.assertEqual(
post.call_args.kwargs["data"],
{
"text": "新版接口测试。",
"emotion": "happy:0.7,calm:0.3",
"emo_alpha": 0.6,
"speed": 1.15,
"seed": 20260713,
"max_mel_tokens": 1500,
"max_text_tokens_per_segment": 120,
"interval_silence": 200,
"temperature": 0.8,
"top_p": 0.8,
"top_k": 30,
"repetition_penalty": 10.0,
"diffusion_steps": 25,
"cfg_rate": 0.7,
"segment_overlap_ms": 50,
},
)
self.assertIn("reference_audio", post.call_args.kwargs["files"])
self.assertEqual(
get.call_args.args[0],
"http://127.0.0.1:7860/outputs/audio/speech.wav",
)
def test_maps_legacy_vector_settings_to_pack_emotion_and_endpoint(self):
voice.config.indextts2.clear()
voice.config.indextts2.update(
{
"api_url": "http://127.0.0.1:7860/tts",
"emotion_mode": "vector",
"emotion_alpha": 0.65,
"vec_happy": 0.3,
"vec_calm": 0.7,
}
)
voice.config.proxy.clear()
generation_response = FakeResponse(content=b"wav-bytes", content_type="audio/wav")
with tempfile.TemporaryDirectory() as temp_dir:
reference_audio = Path(temp_dir) / "reference.wav"
output_file = Path(temp_dir) / "output.wav"
reference_audio.write_bytes(b"reference-wav")
with (
patch("app.services.voice.requests.post", return_value=generation_response) as post,
patch("app.services.voice.get_audio_duration_from_file", return_value=1.0),
):
result = voice.indextts2_tts(
text="旧配置兼容测试。",
voice_name=f"indextts2:{reference_audio}",
voice_file=str(output_file),
)
self.assertIsNotNone(result)
self.assertEqual(
post.call_args.args[0],
"http://127.0.0.1:7860/v1/audio/speech/upload",
)
self.assertEqual(post.call_args.kwargs["data"]["emotion"], "happy:0.3,calm:0.7")
self.assertEqual(post.call_args.kwargs["data"]["emo_alpha"], 0.65)
self.assertNotIn("emotion_mode", post.call_args.kwargs["data"])
self.assertNotIn("vec_happy", post.call_args.kwargs["data"])
def test_normalizes_saved_values_to_pack_request_ranges(self):
voice.config.indextts2.clear()
voice.config.indextts2.update(
{
"api_url": "http://127.0.0.1:7860",
"emo_alpha": 2.0,
"speed": 0.1,
"max_mel_tokens": 50,
"max_text_tokens_per_segment": 1,
"interval_silence": -10,
"temperature": 0.0,
"top_p": 0.0,
"top_k": 0,
"repetition_penalty": 0.1,
"diffusion_steps": 0,
"cfg_rate": -1.0,
"segment_overlap_ms": -5,
"seed": "not-an-integer",
}
)
voice.config.proxy.clear()
generation_response = FakeResponse(content=b"wav-bytes", content_type="audio/wav")
with tempfile.TemporaryDirectory() as temp_dir:
reference_audio = Path(temp_dir) / "reference.wav"
output_file = Path(temp_dir) / "output.wav"
reference_audio.write_bytes(b"reference-wav")
with (
patch("app.services.voice.requests.post", return_value=generation_response) as post,
patch("app.services.voice.get_audio_duration_from_file", return_value=1.0),
):
result = voice.indextts2_tts(
text="参数范围测试。",
voice_name=f"indextts2:{reference_audio}",
voice_file=str(output_file),
)
self.assertIsNotNone(result)
self.assertEqual(
post.call_args.kwargs["data"],
{
"text": "参数范围测试。",
"emo_alpha": 1.0,
"speed": 0.5,
"max_mel_tokens": 64,
"max_text_tokens_per_segment": 20,
"interval_silence": 0,
"temperature": 0.05,
"top_p": 0.05,
"top_k": 1,
"repetition_penalty": 1.0,
"diffusion_steps": 1,
"cfg_rate": 0.0,
"segment_overlap_ms": 0,
},
)
if __name__ == "__main__":
unittest.main()

View File

@ -0,0 +1,102 @@
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, content_type="application/json"):
self.status_code = status_code
self.content = content
self._payload = payload or {}
self.headers = {"content-type": content_type}
self.text = "OK"
def json(self):
return self._payload
class IndexTTSMacOSTtsTests(unittest.TestCase):
def setUp(self):
self.original_config = dict(voice.config.indextts_macos)
self.original_proxy = dict(voice.config.proxy)
def tearDown(self):
voice.config.indextts_macos.clear()
voice.config.indextts_macos.update(self.original_config)
voice.config.proxy.clear()
voice.config.proxy.update(self.original_proxy)
def test_uploads_reference_audio_and_downloads_pack_output_url(self):
voice.config.indextts_macos.clear()
voice.config.indextts_macos.update(
{
"api_url": "http://127.0.0.1:7866",
"speed": 1.1,
"seed": 42,
"max_mel_tokens": 800,
"max_text_tokens_per_segment": 120,
"interval_silence": 200,
"temperature": 1.0,
"top_p": 0.8,
"top_k": 30,
"repetition_penalty": 10.0,
"segment_overlap_ms": 50,
}
)
voice.config.proxy.clear()
generation_response = FakeResponse(payload={"output_url": "/outputs/speech.wav"})
download_response = FakeResponse(content=b"wav-bytes", content_type="audio/wav")
with tempfile.TemporaryDirectory() as temp_dir:
reference_audio = Path(temp_dir) / "reference.wav"
output_file = Path(temp_dir) / "output.wav"
reference_audio.write_bytes(b"reference-wav")
with (
patch("app.services.voice.requests.post", return_value=generation_response) as post,
patch("app.services.voice.requests.get", return_value=download_response) as get,
patch("app.services.voice.get_audio_duration_from_file", return_value=1.25),
):
result = voice.indextts_macos_tts(
text=" macOS 接口测试。 ",
voice_name=f"indextts_macos:{reference_audio}",
voice_file=str(output_file),
)
output_bytes = output_file.read_bytes() if output_file.exists() else b""
self.assertIsNotNone(result)
self.assertEqual(output_bytes, b"wav-bytes")
self.assertEqual(
post.call_args.args[0],
"http://127.0.0.1:7866/v1/audio/speech/upload",
)
self.assertEqual(
post.call_args.kwargs["data"],
{
"text": "macOS 接口测试。",
"speed": 1.1,
"seed": 42,
"max_mel_tokens": 800,
"max_text_tokens_per_segment": 120,
"interval_silence": 200,
"temperature": 1.0,
"top_p": 0.8,
"top_k": 30,
"repetition_penalty": 10.0,
"segment_overlap_ms": 50,
},
)
self.assertIn("reference_audio", post.call_args.kwargs["files"])
self.assertEqual(
get.call_args.args[0],
"http://127.0.0.1:7866/outputs/speech.wav",
)
if __name__ == "__main__":
unittest.main()

View File

@ -27,6 +27,14 @@ class SubtitleCorrectorTests(unittest.TestCase):
} }
with ( 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._ensure_llm_providers_registered"),
mock.patch( mock.patch(
"app.services.subtitle_corrector._run_async_safely", "app.services.subtitle_corrector._run_async_safely",
@ -49,6 +57,8 @@ class SubtitleCorrectorTests(unittest.TestCase):
self.assertEqual("openai", call_kwargs["provider"]) self.assertEqual("openai", call_kwargs["provider"])
self.assertEqual("sk-test", call_kwargs["api_key"]) self.assertEqual("sk-test", call_kwargs["api_key"])
self.assertEqual("https://llm.example/v1", call_kwargs["api_base"]) 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.assertEqual("json", call_kwargs["response_format"])
self.assertIn("多语言字幕校对员", call_kwargs["system_prompt"]) self.assertIn("多语言字幕校对员", call_kwargs["system_prompt"])
self.assertIn("保持原语言", call_kwargs["prompt"]) self.assertIn("保持原语言", call_kwargs["prompt"])

View File

@ -0,0 +1,167 @@
import json
import tempfile
import unittest
from pathlib import Path
from unittest import mock
from app.services import subtitle_translator as translator
SAMPLE_SRT = """1
00:00:01,000 --> 00:00:03,000
Hello, everyone.
2
00:00:04,000 --> 00:00:06,000
We are going to Beijing.
"""
BATCH_SAMPLE_SRT = """1
00:00:01,000 --> 00:00:02,000
Line one.
2
00:00:02,000 --> 00:00:03,000
Line two.
3
00:00:03,000 --> 00:00:04,000
Line three.
4
00:00:04,000 --> 00:00:05,000
Line four.
5
00:00:05,000 --> 00:00:06,000
Line five.
"""
class SubtitleTranslatorTests(unittest.TestCase):
def test_translate_srt_content_preserves_timecodes_and_rebuilds_text(self):
llm_output = {
"items": [
{"id": 1, "text": "大家好。"},
{"id": 2, "text": "我们要去北京。"},
]
}
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",
return_value=json.dumps(llm_output, ensure_ascii=False),
) as run_llm,
):
translated = translator.translate_srt_content(
SAMPLE_SRT,
target_language="中文",
provider="openai",
api_key="sk-test",
base_url="https://llm.example/v1",
)
self.assertIn("00:00:01,000 --> 00:00:03,000", translated)
self.assertIn("大家好。", translated)
self.assertIn("我们要去北京。", translated)
self.assertNotIn("Hello, everyone.", translated)
call_kwargs = run_llm.call_args.kwargs
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"])
def test_translate_srt_content_rejects_missing_items(self):
llm_output = {"items": [{"id": 1, "text": "大家好。"}]}
with (
mock.patch("app.services.subtitle_translator._ensure_llm_providers_registered"),
mock.patch(
"app.services.subtitle_translator._run_async_safely",
return_value=json.dumps(llm_output, ensure_ascii=False),
),
):
with self.assertRaises(translator.SubtitleTranslationError):
translator.translate_srt_content(SAMPLE_SRT, provider="openai")
def test_translate_subtitle_file_writes_translated_srt(self):
llm_output = {
"items": [
{"id": 1, "text": "大家好。"},
{"id": 2, "text": "我们要去北京。"},
]
}
with tempfile.TemporaryDirectory() as tmp_dir:
input_file = Path(tmp_dir) / "input.srt"
output_file = Path(tmp_dir) / "output.srt"
input_file.write_text(SAMPLE_SRT, encoding="utf-8")
with (
mock.patch("app.services.subtitle_translator._ensure_llm_providers_registered"),
mock.patch(
"app.services.subtitle_translator._run_async_safely",
return_value=json.dumps(llm_output, ensure_ascii=False),
),
):
result_path = translator.translate_subtitle_file(
str(input_file),
str(output_file),
target_language="中文",
provider="openai",
)
self.assertEqual(str(output_file), result_path)
self.assertIn("大家好。", output_file.read_text(encoding="utf-8"))
def test_translate_srt_content_batches_requests_and_reports_progress(self):
progress_events = []
def fake_run_llm(*args, **kwargs):
payload_text = kwargs["prompt"].rsplit("待翻译字幕条目:", 1)[1].strip()
payload = json.loads(payload_text)
translated = {key: f"译文{key}" for key in payload}
return json.dumps(translated, ensure_ascii=False)
with (
mock.patch("app.services.subtitle_translator._ensure_llm_providers_registered"),
mock.patch(
"app.services.subtitle_translator._run_async_safely",
side_effect=fake_run_llm,
) as run_llm,
):
translated = translator.translate_srt_content(
BATCH_SAMPLE_SRT,
target_language="中文",
provider="openai",
batch_size=2,
max_workers=1,
progress_callback=lambda completed, total, message: progress_events.append(
(completed, total, message)
),
)
self.assertEqual(3, run_llm.call_count)
self.assertIn("译文1", translated)
self.assertIn("译文5", translated)
self.assertEqual((0, 5), progress_events[0][:2])
self.assertEqual((5, 5), progress_events[-1][:2])
self.assertTrue(any("完成批次" in event[2] for event in progress_events))
if __name__ == "__main__":
unittest.main()

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

@ -1150,14 +1150,13 @@ def doubaotts_tts(text: str, voice_name: str, voice_file: str, speed: float = 1.
""" """
# 读取配置 # 读取配置
doubaotts_cfg = getattr(config, "doubaotts", {}) or {} doubaotts_cfg = getattr(config, "doubaotts", {}) or {}
api_key = (doubaotts_cfg.get("api_key", "") or doubaotts_cfg.get("apikey", "")).strip()
appid = doubaotts_cfg.get("appid", "") appid = doubaotts_cfg.get("appid", "")
token = doubaotts_cfg.get("token", "") token = doubaotts_cfg.get("token", "")
ak = doubaotts_cfg.get("ak", "")
sk = doubaotts_cfg.get("sk", "")
cluster = doubaotts_cfg.get("cluster", "volcano_tts") cluster = doubaotts_cfg.get("cluster", "volcano_tts")
if not appid or not token: if not api_key and (not appid or not token):
logger.error("豆包语音 TTS 配置未完成") logger.error("豆包语音 TTS 配置未完成,请配置 API Key或填写旧版 AppID 和 Token")
return None return None
# 准备参数 # 准备参数
@ -1174,12 +1173,15 @@ def doubaotts_tts(text: str, voice_name: str, voice_file: str, speed: float = 1.
pitch = doubaotts_cfg.get("pitch", 1.0) pitch = doubaotts_cfg.get("pitch", 1.0)
silence_duration = doubaotts_cfg.get("silence_duration", 0.125) silence_duration = doubaotts_cfg.get("silence_duration", 0.125)
payload = { app_payload = {"cluster": cluster}
"app": { if not api_key:
app_payload.update({
"appid": appid, "appid": appid,
"token": token, "token": token,
"cluster": cluster })
},
payload = {
"app": app_payload,
"user": { "user": {
"uid": "NarratoAI" "uid": "NarratoAI"
}, },
@ -1206,11 +1208,14 @@ def doubaotts_tts(text: str, voice_name: str, voice_file: str, speed: float = 1.
# API 地址 # API 地址
url = "https://openspeech.bytedance.com/api/v1/tts" url = "https://openspeech.bytedance.com/api/v1/tts"
# 构建请求头使用Bearer Token认证 # 构建请求头。新版控制台优先使用 API Key旧配置继续使用 Token。
headers = { headers = {
"Content-Type": "application/json", "Content-Type": "application/json",
"Authorization": f"Bearer;{token}"
} }
if api_key:
headers["X-Api-Key"] = api_key
else:
headers["Authorization"] = f"Bearer;{token}"
for i in range(3): for i in range(3):
try: try:
@ -1297,6 +1302,10 @@ def tts(
logger.info("分发到 IndexTTS-1.5") logger.info("分发到 IndexTTS-1.5")
return indextts_tts(text, voice_name, voice_file, speed=voice_rate) return indextts_tts(text, voice_name, voice_file, speed=voice_rate)
if tts_engine == config.INDEXTTS_MACOS_ENGINE:
logger.info("分发到 IndexTTS-1.5-macOS")
return indextts_macos_tts(text, voice_name, voice_file)
if tts_engine == config.INDEXTTS2_ENGINE: if tts_engine == config.INDEXTTS2_ENGINE:
logger.info("分发到 IndexTTS-2") logger.info("分发到 IndexTTS-2")
return indextts2_tts(text, voice_name, voice_file) return indextts2_tts(text, voice_name, voice_file)
@ -1305,6 +1314,14 @@ def tts(
logger.info("分发到 OmniVoice") logger.info("分发到 OmniVoice")
return omnivoice_tts(text, voice_name, voice_file, speed=voice_rate) 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": if tts_engine == "doubaotts":
logger.info("分发到豆包语音 TTS") logger.info("分发到豆包语音 TTS")
return doubaotts_tts(text, voice_name, voice_file, speed=voice_rate) return doubaotts_tts(text, voice_name, voice_file, speed=voice_rate)
@ -1791,8 +1808,11 @@ def tts_multiple(task_id: str, list_script: list, voice_name: str, voice_rate: f
tts_results = [] tts_results = []
audio_extension = ".wav" if tts_engine in ( audio_extension = ".wav" if tts_engine in (
config.INDEXTTS_ENGINE, config.INDEXTTS_ENGINE,
config.INDEXTTS_MACOS_ENGINE,
config.INDEXTTS2_ENGINE, config.INDEXTTS2_ENGINE,
config.OMNIVOICE_ENGINE, config.OMNIVOICE_ENGINE,
config.VOXCPM_ENGINE,
config.VOXCPM2_ENGINE,
) else ".mp3" ) else ".mp3"
for item in list_script: for item in list_script:
@ -1823,7 +1843,14 @@ def tts_multiple(task_id: str, list_script: list, voice_name: str, voice_rate: f
if ( if (
is_soulvoice_voice(voice_name) is_soulvoice_voice(voice_name)
or is_qwen_engine(tts_engine) or is_qwen_engine(tts_engine)
or tts_engine in (config.INDEXTTS_ENGINE, config.INDEXTTS2_ENGINE, config.OMNIVOICE_ENGINE) or tts_engine in (
config.INDEXTTS_ENGINE,
config.INDEXTTS_MACOS_ENGINE,
config.INDEXTTS2_ENGINE,
config.OMNIVOICE_ENGINE,
config.VOXCPM_ENGINE,
config.VOXCPM2_ENGINE,
)
or tts_engine == "doubaotts" or tts_engine == "doubaotts"
): ):
# 获取实际音频文件的时长 # 获取实际音频文件的时长
@ -2266,6 +2293,13 @@ def parse_indextts2_voice(voice_name: str) -> str:
return voice_name return voice_name
def parse_indextts_macos_voice(voice_name: str) -> str:
"""解析 IndexTTS-1.5-macOS 参考音频路径。"""
if isinstance(voice_name, str) and voice_name.startswith(config.INDEXTTS_MACOS_VOICE_PREFIX):
return voice_name[len(config.INDEXTTS_MACOS_VOICE_PREFIX):]
return voice_name
def parse_omnivoice_voice(voice_name: str) -> str: def parse_omnivoice_voice(voice_name: str) -> str:
""" """
解析 OmniVoice 语音名称 解析 OmniVoice 语音名称
@ -2277,6 +2311,20 @@ def parse_omnivoice_voice(voice_name: str) -> str:
return voice_name 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]: def indextts_tts(text: str, voice_name: str, voice_file: str, speed: float = 1.0) -> Union[SubMaker, None]:
""" """
使用 IndexTTS-1.5 API 进行零样本语音克隆 使用 IndexTTS-1.5 API 进行零样本语音克隆
@ -2390,10 +2438,166 @@ def indextts_tts(text: str, voice_name: str, voice_file: str, speed: float = 1.0
def _normalize_indextts2_api_url(api_url: str) -> str: def _normalize_indextts2_api_url(api_url: str) -> str:
api_url = (api_url or "http://192.168.3.6:7863/tts").strip() """Return the IndexTTS-2 MLX Pack upload endpoint for a configured URL.
if api_url.endswith("/tts"):
The Pack accepts a server root, the JSON speech endpoint, or the multipart
upload endpoint. Treat an old ``/tts`` value as a server root so existing
saved settings move to the new Pack route instead of continuing to 404.
"""
api_url = (api_url or "http://127.0.0.1:7860").strip().rstrip("/")
upload_path = "/v1/audio/speech/upload"
speech_path = "/v1/audio/speech"
if api_url.endswith(upload_path):
return api_url return api_url
return f"{api_url.rstrip('/')}/tts" if api_url.endswith(speech_path):
return f"{api_url}/upload"
if api_url.endswith("/tts"):
api_url = api_url[: -len("/tts")]
return f"{api_url}{upload_path}"
def _normalize_indextts_macos_api_url(api_url: str) -> str:
"""Return the IndexTTS 1.5 MLX Pack multipart upload endpoint."""
api_url = (api_url or "http://127.0.0.1:7866").strip().rstrip("/")
upload_path = "/v1/audio/speech/upload"
speech_path = "/v1/audio/speech"
if api_url.endswith(upload_path):
return api_url
if api_url.endswith(speech_path):
return f"{api_url}/upload"
return f"{api_url}{upload_path}"
def _get_indextts_macos_number(
key: str,
default: float | int,
minimum: float | int,
maximum: float | int,
*,
integer: bool = False,
) -> float | int:
try:
raw_value = config.indextts_macos.get(key, default)
value = int(float(raw_value)) if integer else float(raw_value)
except (TypeError, ValueError):
value = default
return max(minimum, min(maximum, value))
def _get_indextts_macos_seed() -> int | None:
seed = config.indextts_macos.get("seed")
if seed in (None, ""):
return None
try:
return int(seed)
except (TypeError, ValueError):
logger.warning("IndexTTS-1.5-macOS 随机种子无效,将使用随机采样: {}", seed)
return None
def _download_indextts_macos_audio(
response: requests.Response,
api_url: str,
voice_file: str,
proxies: dict,
) -> bool:
try:
result = response.json()
except ValueError:
logger.error("IndexTTS-1.5-macOS API 返回了无效的 JSON 响应")
return False
download_url = result.get("output_url") if isinstance(result, dict) else ""
if not download_url:
logger.error(f"IndexTTS-1.5-macOS API 响应中没有音频下载地址: {result}")
return False
audio_response = requests.get(
urljoin(api_url, download_url),
proxies=proxies,
timeout=120,
)
if audio_response.status_code != 200:
logger.error(
f"IndexTTS-1.5-macOS 音频下载失败: "
f"{audio_response.status_code} - {audio_response.text}"
)
return False
with open(voice_file, "wb") as f:
f.write(audio_response.content)
return os.path.getsize(voice_file) > 0
def indextts_macos_tts(text: str, voice_name: str, voice_file: str) -> Union[SubMaker, None]:
"""使用 IndexTTS-MLX-1.5-Pack 的上传接口进行零样本语音克隆。"""
api_url = _normalize_indextts_macos_api_url(
config.indextts_macos.get("api_url", "http://127.0.0.1:7866")
)
reference_audio_path = parse_indextts_macos_voice(voice_name)
if not reference_audio_path or not os.path.exists(reference_audio_path):
logger.error(f"IndexTTS-1.5-macOS 参考音频文件不存在: {reference_audio_path}")
return None
data = {
"text": text.strip(),
"speed": _get_indextts_macos_number("speed", 1.0, 0.5, 2.0),
"max_mel_tokens": _get_indextts_macos_number(
"max_mel_tokens", 800, 64, 1600, integer=True
),
"max_text_tokens_per_segment": _get_indextts_macos_number(
"max_text_tokens_per_segment", 120, 20, 600, integer=True
),
"interval_silence": _get_indextts_macos_number(
"interval_silence", 200, 0, 2000, integer=True
),
"temperature": _get_indextts_macos_number("temperature", 1.0, 0.0, 2.0),
"top_p": _get_indextts_macos_number("top_p", 0.8, 0.05, 1.0),
"top_k": _get_indextts_macos_number("top_k", 30, 0, 200, integer=True),
"repetition_penalty": _get_indextts_macos_number(
"repetition_penalty", 10.0, 1.0, 20.0
),
"segment_overlap_ms": _get_indextts_macos_number(
"segment_overlap_ms", 50, 0, 500, integer=True
),
}
seed = _get_indextts_macos_seed()
if seed is not None:
data["seed"] = seed
proxies = _get_configured_proxies()
for attempt in range(3):
try:
with open(reference_audio_path, "rb") as reference_audio:
logger.info(f"{attempt + 1} 次调用 IndexTTS-1.5-macOS API: {api_url}")
response = requests.post(
api_url,
files={"reference_audio": reference_audio},
data=data,
proxies=proxies,
timeout=600,
)
if response.status_code == 200 and _download_indextts_macos_audio(
response, api_url, voice_file, proxies
):
sub_maker = new_sub_maker()
duration = get_audio_duration_from_file(voice_file)
duration_ms = int(duration * 1000) if duration > 0 else max(1000, int(len(text) * 200))
add_subtitle_event(sub_maker, 0, duration_ms * 10000, text)
return sub_maker
logger.error(
f"IndexTTS-1.5-macOS API 调用失败: {response.status_code} - {response.text}"
)
except requests.exceptions.Timeout:
logger.error(f"IndexTTS-1.5-macOS API 调用超时 (尝试 {attempt + 1}/3)")
except requests.exceptions.RequestException as e:
logger.error(f"IndexTTS-1.5-macOS API 网络错误: {str(e)} (尝试 {attempt + 1}/3)")
except Exception as e:
logger.error(f"IndexTTS-1.5-macOS TTS 处理错误: {str(e)} (尝试 {attempt + 1}/3)")
if attempt < 2:
time.sleep(2)
return None
def _get_configured_proxies() -> dict: def _get_configured_proxies() -> dict:
@ -2405,6 +2609,49 @@ def _get_configured_proxies() -> dict:
} }
def _get_indextts2_number(
key: str,
default: float | int,
minimum: float | int,
maximum: float | int,
*,
integer: bool = False,
) -> float | int:
"""Read an IndexTTS-2 option and constrain it to the MLX Pack schema."""
try:
raw_value = config.indextts2.get(key, default)
value = int(float(raw_value)) if integer else float(raw_value)
except (TypeError, ValueError):
value = default
return max(minimum, min(maximum, value))
def _get_indextts2_seed() -> int | None:
"""Return the optional Pack seed, omitting invalid legacy text values."""
seed = config.indextts2.get("seed")
if seed in (None, ""):
return None
try:
return int(seed)
except (TypeError, ValueError):
logger.warning("IndexTTS-2 随机种子无效,将使用随机采样: {}", seed)
return None
def _get_indextts2_emotion() -> str:
"""Map current and legacy IndexTTS-2 emotion settings to the MLX Pack API."""
emotion = config.get_indextts2_pack_emotion(config.indextts2)
if emotion:
return emotion
emotion_mode = config.indextts2.get("emotion_mode", "speaker")
if emotion_mode == "audio" and config.indextts2.get("emotion_audio"):
logger.warning(
"IndexTTS-2 MLX Pack 不支持单独的情感参考音频,将使用音色参考音频的情感。"
)
return ""
def _download_indextts2_audio(response: requests.Response, api_url: str, voice_file: str, proxies: dict) -> bool: def _download_indextts2_audio(response: requests.Response, api_url: str, voice_file: str, proxies: dict) -> bool:
content_type = response.headers.get("content-type", "").lower() content_type = response.headers.get("content-type", "").lower()
if "application/json" not in content_type: if "application/json" not in content_type:
@ -2412,9 +2659,14 @@ def _download_indextts2_audio(response: requests.Response, api_url: str, voice_f
f.write(response.content) f.write(response.content)
return os.path.getsize(voice_file) > 0 return os.path.getsize(voice_file) > 0
try:
result = response.json() result = response.json()
downloads = result.get("downloads") if isinstance(result, dict) else {} except ValueError:
download_url = downloads.get("wav") if isinstance(downloads, dict) else "" logger.error("IndexTTS-2 API 返回了无效的 JSON 响应")
return False
output = result.get("output") if isinstance(result, dict) else {}
download_url = output.get("url") if isinstance(output, dict) else ""
if not download_url: if not download_url:
logger.error(f"IndexTTS-2 API 响应中没有音频下载地址: {result}") logger.error(f"IndexTTS-2 API 响应中没有音频下载地址: {result}")
return False return False
@ -2432,56 +2684,62 @@ def _download_indextts2_audio(response: requests.Response, api_url: str, voice_f
def indextts2_tts(text: str, voice_name: str, voice_file: str) -> Union[SubMaker, None]: def indextts2_tts(text: str, voice_name: str, voice_file: str) -> Union[SubMaker, None]:
""" """
使用 IndexTTS-2 API 进行零样本语音克隆 使用 IndexTTS-2 MLX Pack API 进行零样本语音克隆
接口兼容 IndexTTS2-Pack POST /tts multipart form
参考音频通过 ``POST /v1/audio/speech/upload`` 上传这样 Pack 即使
运行在另一台机器上也不需要访问 NarratoAI 的本地文件路径
""" """
api_url = _normalize_indextts2_api_url(config.indextts2.get("api_url", "http://192.168.3.6:7863/tts")) api_url = _normalize_indextts2_api_url(config.indextts2.get("api_url", "http://127.0.0.1:7860"))
reference_audio_path = parse_indextts2_voice(voice_name) reference_audio_path = parse_indextts2_voice(voice_name)
if not reference_audio_path or not os.path.exists(reference_audio_path): if not reference_audio_path or not os.path.exists(reference_audio_path):
logger.error(f"IndexTTS-2 参考音频文件不存在: {reference_audio_path}") logger.error(f"IndexTTS-2 参考音频文件不存在: {reference_audio_path}")
return None return None
emotion_mode = config.indextts2.get("emotion_mode", "speaker")
emotion_audio_path = config.indextts2.get("emotion_audio", "")
data = { data = {
"text": text.strip(), "text": text.strip(),
"emotion_mode": emotion_mode, "emo_alpha": _get_indextts2_number(
"emotion_alpha": config.indextts2.get("emotion_alpha", 0.65), "emo_alpha", config.indextts2.get("emotion_alpha", 0.6), 0.0, 1.0
"emotion_text": config.indextts2.get("emotion_text", ""), ),
"use_random": str(bool(config.indextts2.get("use_random", False))).lower(), "speed": _get_indextts2_number("speed", 1.0, 0.5, 2.0),
"max_text_tokens_per_segment": config.indextts2.get("max_text_tokens_per_segment", 120), "max_mel_tokens": _get_indextts2_number(
"vec_happy": config.indextts2.get("vec_happy", 0.0), "max_mel_tokens", 1500, 64, 1815, integer=True
"vec_angry": config.indextts2.get("vec_angry", 0.0), ),
"vec_sad": config.indextts2.get("vec_sad", 0.0), "max_text_tokens_per_segment": _get_indextts2_number(
"vec_afraid": config.indextts2.get("vec_afraid", 0.0), "max_text_tokens_per_segment", 120, 20, 600, integer=True
"vec_disgusted": config.indextts2.get("vec_disgusted", 0.0), ),
"vec_melancholic": config.indextts2.get("vec_melancholic", 0.0), "interval_silence": _get_indextts2_number(
"vec_surprised": config.indextts2.get("vec_surprised", 0.0), "interval_silence", 200, 0, 5000, integer=True
"vec_calm": config.indextts2.get("vec_calm", 0.8), ),
"temperature": config.indextts2.get("temperature", 0.8), "temperature": _get_indextts2_number("temperature", 0.8, 0.05, 2.0),
"top_p": config.indextts2.get("top_p", 0.8), "top_p": _get_indextts2_number("top_p", 0.8, 0.05, 1.0),
"top_k": config.indextts2.get("top_k", 30), "top_k": _get_indextts2_number("top_k", 30, 1, 200, integer=True),
"num_beams": config.indextts2.get("num_beams", 3), "repetition_penalty": _get_indextts2_number(
"repetition_penalty": config.indextts2.get("repetition_penalty", 10.0), "repetition_penalty", 10.0, 1.0, 30.0
"max_mel_tokens": config.indextts2.get("max_mel_tokens", 1500), ),
"diffusion_steps": _get_indextts2_number(
"diffusion_steps", 25, 1, 100, integer=True
),
"cfg_rate": _get_indextts2_number("cfg_rate", 0.7, 0.0, 2.0),
"segment_overlap_ms": _get_indextts2_number(
"segment_overlap_ms", 50, 0, 1000, integer=True
),
} }
emotion = _get_indextts2_emotion()
if emotion:
data["emotion"] = emotion
seed = _get_indextts2_seed()
if seed is not None:
data["seed"] = seed
proxies = _get_configured_proxies() proxies = _get_configured_proxies()
for attempt in range(3): for attempt in range(3):
files = {}
try: try:
files["speaker_audio"] = open(reference_audio_path, "rb") with open(reference_audio_path, "rb") as reference_audio:
if emotion_mode == "audio":
if not emotion_audio_path or not os.path.exists(emotion_audio_path):
logger.error(f"IndexTTS-2 情感参考音频文件不存在: {emotion_audio_path}")
return None
files["emotion_audio"] = open(emotion_audio_path, "rb")
logger.info(f"{attempt + 1} 次调用 IndexTTS-2 API: {api_url}") logger.info(f"{attempt + 1} 次调用 IndexTTS-2 API: {api_url}")
response = requests.post( response = requests.post(
api_url, api_url,
files=files, files={"reference_audio": reference_audio},
data=data, data=data,
proxies=proxies, proxies=proxies,
timeout=180, timeout=180,
@ -2502,12 +2760,6 @@ def indextts2_tts(text: str, voice_name: str, voice_file: str) -> Union[SubMaker
logger.error(f"IndexTTS-2 API 网络错误: {str(e)} (尝试 {attempt + 1}/3)") logger.error(f"IndexTTS-2 API 网络错误: {str(e)} (尝试 {attempt + 1}/3)")
except Exception as e: except Exception as e:
logger.error(f"IndexTTS-2 TTS 处理错误: {str(e)} (尝试 {attempt + 1}/3)") logger.error(f"IndexTTS-2 TTS 处理错误: {str(e)} (尝试 {attempt + 1}/3)")
finally:
for file_obj in files.values():
try:
file_obj.close()
except Exception:
pass
if attempt < 2: if attempt < 2:
time.sleep(2) time.sleep(2)
@ -2516,6 +2768,144 @@ def indextts2_tts(text: str, voice_name: str, voice_file: str) -> Union[SubMaker
return None 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: def _normalize_omnivoice_api_url(api_url: str) -> str:
api_url = (api_url or "http://127.0.0.1:7866/tts").strip() api_url = (api_url or "http://127.0.0.1:7866/tts").strip()
if api_url.endswith("/tts"): if api_url.endswith("/tts"):

View File

@ -1,326 +0,0 @@
import json
from typing import List, Union, Dict
import os
from pathlib import Path
from loguru import logger
from tqdm import tqdm
import asyncio
from tenacity import retry, stop_after_attempt, retry_if_exception_type, wait_exponential
import requests
import PIL.Image
import traceback
import base64
import io
from app.utils import utils
class VisionAnalyzer:
"""原生Gemini视觉分析器类"""
def __init__(self, model_name: str = "gemini-2.0-flash-exp", api_key: str = None, base_url: str = None):
"""初始化视觉分析器"""
if not api_key:
raise ValueError("必须提供API密钥")
self.model_name = model_name
self.api_key = api_key
self.base_url = base_url or "https://generativelanguage.googleapis.com/v1beta"
# 初始化配置
self._configure_client()
def _configure_client(self):
"""配置原生Gemini API客户端"""
# 使用原生Gemini REST API
self.client = None
logger.info(f"配置原生Gemini API端点: {self.base_url}, 模型: {self.model_name}")
@retry(
stop=stop_after_attempt(3),
wait=wait_exponential(multiplier=1, min=4, max=10),
retry=retry_if_exception_type(requests.exceptions.RequestException)
)
async def _generate_content_with_retry(self, prompt, batch):
"""使用重试机制调用原生Gemini API"""
try:
return await self._generate_with_gemini_api(prompt, batch)
except requests.exceptions.RequestException as e:
logger.warning(f"Gemini API请求异常: {str(e)}")
raise
except Exception as e:
logger.error(f"Gemini API生成内容时发生错误: {str(e)}")
raise
async def _generate_with_gemini_api(self, prompt, batch):
"""使用原生Gemini REST API生成内容"""
# 将PIL图片转换为base64编码
image_parts = []
for img in batch:
# 将PIL图片转换为字节流
img_buffer = io.BytesIO()
img.save(img_buffer, format='JPEG', quality=85) # 优化图片质量
img_bytes = img_buffer.getvalue()
# 转换为base64
img_base64 = base64.b64encode(img_bytes).decode('utf-8')
image_parts.append({
"inline_data": {
"mime_type": "image/jpeg",
"data": img_base64
}
})
# 构建符合官方文档的请求数据
request_data = {
"contents": [{
"parts": [
{"text": prompt},
*image_parts
]
}],
"generationConfig": {
"temperature": 1.0,
"topK": 40,
"topP": 0.95,
"maxOutputTokens": 8192,
"candidateCount": 1,
"stopSequences": []
},
"safetySettings": [
{
"category": "HARM_CATEGORY_HARASSMENT",
"threshold": "BLOCK_NONE"
},
{
"category": "HARM_CATEGORY_HATE_SPEECH",
"threshold": "BLOCK_NONE"
},
{
"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT",
"threshold": "BLOCK_NONE"
},
{
"category": "HARM_CATEGORY_DANGEROUS_CONTENT",
"threshold": "BLOCK_NONE"
}
]
}
# 构建请求URL
url = f"{self.base_url}/models/{self.model_name}:generateContent"
# 发送请求
response = await asyncio.to_thread(
requests.post,
url,
json=request_data,
headers={
"Content-Type": "application/json",
"x-goog-api-key": self.api_key
},
timeout=120 # 增加超时时间
)
# 处理HTTP错误
if response.status_code == 429:
raise requests.exceptions.RequestException(f"API配额限制: {response.text}")
elif response.status_code == 400:
raise Exception(f"请求参数错误: {response.text}")
elif response.status_code == 403:
raise Exception(f"API密钥无效或权限不足: {response.text}")
elif response.status_code != 200:
raise Exception(f"Gemini API请求失败: {response.status_code} - {response.text}")
response_data = response.json()
# 检查响应格式
if "candidates" not in response_data or not response_data["candidates"]:
raise Exception("Gemini API返回无效响应可能触发了安全过滤")
candidate = response_data["candidates"][0]
# 检查是否被安全过滤阻止
if "finishReason" in candidate and candidate["finishReason"] == "SAFETY":
raise Exception("内容被Gemini安全过滤器阻止")
if "content" not in candidate or "parts" not in candidate["content"]:
raise Exception("Gemini API返回内容格式错误")
# 提取文本内容
text_content = ""
for part in candidate["content"]["parts"]:
if "text" in part:
text_content += part["text"]
if not text_content.strip():
raise Exception("Gemini API返回空内容")
# 创建兼容的响应对象
class CompatibleResponse:
def __init__(self, text):
self.text = text
return CompatibleResponse(text_content)
async def analyze_images(self,
images: Union[List[str], List[PIL.Image.Image]],
prompt: str,
batch_size: int) -> List[Dict]:
"""批量分析多张图片"""
try:
# 加载图片
if isinstance(images[0], str):
images = self.load_images(images)
# 验证图片列表
if not images:
raise ValueError("图片列表为空")
# 验证每个图片对象
valid_images = []
for i, img in enumerate(images):
if not isinstance(img, PIL.Image.Image):
logger.error(f"无效的图片对象,索引 {i}: {type(img)}")
continue
valid_images.append(img)
if not valid_images:
raise ValueError("没有有效的图片对象")
images = valid_images
results = []
# 视频帧总数除以批量处理大小,如果有小数则+1
batches_needed = len(images) // batch_size
if len(images) % batch_size > 0:
batches_needed += 1
logger.debug(f"视频帧总数:{len(images)}, 每批处理 {batch_size} 帧, 需要访问 VLM {batches_needed}")
with tqdm(total=batches_needed, desc="分析进度") as pbar:
for i in range(0, len(images), batch_size):
batch = images[i:i + batch_size]
retry_count = 0
while retry_count < 3:
try:
# 在每个批次处理前添加小延迟
# if i > 0:
# await asyncio.sleep(2)
# 确保每个批次的图片都是有效的
valid_batch = [img for img in batch if isinstance(img, PIL.Image.Image)]
if not valid_batch:
raise ValueError(f"批次 {i // batch_size} 中没有有效的图片")
response = await self._generate_content_with_retry(prompt, valid_batch)
results.append({
'batch_index': i // batch_size,
'images_processed': len(valid_batch),
'response': response.text,
'model_used': self.model_name
})
break
except Exception as e:
retry_count += 1
error_msg = f"批次 {i // batch_size} 处理出错: {str(e)}"
logger.error(error_msg)
if retry_count >= 3:
results.append({
'batch_index': i // batch_size,
'images_processed': len(batch),
'error': error_msg,
'model_used': self.model_name
})
else:
logger.info(f"批次 {i // batch_size} 处理失败等待60秒后重试当前批次...")
await asyncio.sleep(60)
pbar.update(1)
return results
except Exception as e:
error_msg = f"图片分析过程中发生错误: {str(e)}\n{traceback.format_exc()}"
logger.error(error_msg)
raise Exception(error_msg)
def save_results_to_txt(self, results: List[Dict], output_dir: str):
"""将分析结果保存到txt文件"""
# 确保输出目录存在
os.makedirs(output_dir, exist_ok=True)
for result in results:
if not result.get('image_paths'):
continue
response_text = result['response']
image_paths = result['image_paths']
# 从文件名中提取时间戳并转换为标准格式
def format_timestamp(img_path):
# 从文件名中提取时间部分
timestamp = Path(img_path).stem.split('_')[-1]
try:
# 将时间转换为秒
seconds = utils.time_to_seconds(timestamp.replace('_', ':'))
# 转换为 HH:MM:SS,mmm 格式
hours = int(seconds // 3600)
minutes = int((seconds % 3600) // 60)
seconds_remainder = seconds % 60
whole_seconds = int(seconds_remainder)
milliseconds = int((seconds_remainder - whole_seconds) * 1000)
return f"{hours:02d}:{minutes:02d}:{whole_seconds:02d},{milliseconds:03d}"
except Exception as e:
logger.error(f"时间戳格式转换错误: {timestamp}, {str(e)}")
return timestamp
start_timestamp = format_timestamp(image_paths[0])
end_timestamp = format_timestamp(image_paths[-1])
txt_path = os.path.join(output_dir, f"frame_{start_timestamp}_{end_timestamp}.txt")
# 保存结果到txt文件
with open(txt_path, 'w', encoding='utf-8') as f:
f.write(response_text.strip())
logger.info(f"已保存分析结果到: {txt_path}")
def load_images(self, image_paths: List[str]) -> List[PIL.Image.Image]:
"""
加载多张图片
Args:
image_paths: 图片路径列表
Returns:
加载后的PIL Image对象列表
"""
images = []
failed_images = []
for img_path in image_paths:
try:
if not os.path.exists(img_path):
logger.error(f"图片文件不存在: {img_path}")
failed_images.append(img_path)
continue
img = PIL.Image.open(img_path)
# 确保图片被完全加载
img.load()
# 转换为RGB模式
if img.mode != 'RGB':
img = img.convert('RGB')
images.append(img)
except Exception as e:
logger.error(f"无法加载图片 {img_path}: {str(e)}")
failed_images.append(img_path)
if failed_images:
logger.warning(f"以下图片加载失败:\n{json.dumps(failed_images, indent=2, ensure_ascii=False)}")
if not images:
raise ValueError("没有成功加载任何图片")
return images

View File

@ -1,177 +0,0 @@
"""
OpenAI兼容的Gemini视觉分析器
使用标准OpenAI格式调用Gemini代理服务
"""
import json
from typing import List, Union, Dict
import os
from pathlib import Path
from loguru import logger
from tqdm import tqdm
import asyncio
from tenacity import retry, stop_after_attempt, retry_if_exception_type, wait_exponential
import requests
import PIL.Image
import traceback
import base64
import io
from app.utils import utils
class GeminiOpenAIAnalyzer:
"""OpenAI兼容的Gemini视觉分析器类"""
def __init__(self, model_name: str = "gemini-2.0-flash-exp", api_key: str = None, base_url: str = None):
"""初始化OpenAI兼容的Gemini分析器"""
if not api_key:
raise ValueError("必须提供API密钥")
if not base_url:
raise ValueError("必须提供OpenAI兼容的代理端点URL")
self.model_name = model_name
self.api_key = api_key
self.base_url = base_url.rstrip('/')
# 初始化OpenAI客户端
self._configure_client()
def _configure_client(self):
"""配置OpenAI兼容的客户端"""
from openai import OpenAI
self.client = OpenAI(
api_key=self.api_key,
base_url=self.base_url
)
logger.info(f"配置OpenAI兼容Gemini代理端点: {self.base_url}, 模型: {self.model_name}")
@retry(
stop=stop_after_attempt(3),
wait=wait_exponential(multiplier=1, min=4, max=10),
retry=retry_if_exception_type((requests.exceptions.RequestException, Exception))
)
async def _generate_content_with_retry(self, prompt, batch):
"""使用重试机制调用OpenAI兼容的Gemini代理"""
try:
return await self._generate_with_openai_api(prompt, batch)
except Exception as e:
logger.warning(f"OpenAI兼容Gemini代理请求异常: {str(e)}")
raise
async def _generate_with_openai_api(self, prompt, batch):
"""使用OpenAI兼容接口生成内容"""
# 将PIL图片转换为base64编码
image_contents = []
for img in batch:
# 将PIL图片转换为字节流
img_buffer = io.BytesIO()
img.save(img_buffer, format='JPEG', quality=85)
img_bytes = img_buffer.getvalue()
# 转换为base64
img_base64 = base64.b64encode(img_bytes).decode('utf-8')
image_contents.append({
"type": "image_url",
"image_url": {
"url": f"data:image/jpeg;base64,{img_base64}"
}
})
# 构建OpenAI格式的消息
messages = [
{
"role": "user",
"content": [
{"type": "text", "text": prompt},
*image_contents
]
}
]
# 调用OpenAI兼容接口
response = await asyncio.to_thread(
self.client.chat.completions.create,
model=self.model_name,
messages=messages,
max_tokens=4000,
temperature=1.0
)
# 创建兼容的响应对象
class CompatibleResponse:
def __init__(self, text):
self.text = text
return CompatibleResponse(response.choices[0].message.content)
async def analyze_images(self,
images: List[Union[str, Path, PIL.Image.Image]],
prompt: str,
batch_size: int = 10) -> List[str]:
"""
分析图片并返回结果
Args:
images: 图片路径列表或PIL图片对象列表
prompt: 分析提示词
batch_size: 批处理大小
Returns:
分析结果列表
"""
logger.info(f"开始分析 {len(images)} 张图片使用OpenAI兼容Gemini代理")
# 加载图片
loaded_images = []
for img in images:
if isinstance(img, (str, Path)):
try:
pil_img = PIL.Image.open(img)
# 调整图片大小以优化性能
if pil_img.size[0] > 1024 or pil_img.size[1] > 1024:
pil_img.thumbnail((1024, 1024), PIL.Image.Resampling.LANCZOS)
loaded_images.append(pil_img)
except Exception as e:
logger.error(f"加载图片失败 {img}: {str(e)}")
continue
elif isinstance(img, PIL.Image.Image):
loaded_images.append(img)
else:
logger.warning(f"不支持的图片类型: {type(img)}")
continue
if not loaded_images:
raise ValueError("没有有效的图片可以分析")
# 分批处理
results = []
total_batches = (len(loaded_images) + batch_size - 1) // batch_size
for i in tqdm(range(0, len(loaded_images), batch_size),
desc="分析图片批次", total=total_batches):
batch = loaded_images[i:i + batch_size]
try:
response = await self._generate_content_with_retry(prompt, batch)
results.append(response.text)
# 添加延迟以避免API限流
if i + batch_size < len(loaded_images):
await asyncio.sleep(1)
except Exception as e:
logger.error(f"分析批次 {i//batch_size + 1} 失败: {str(e)}")
results.append(f"分析失败: {str(e)}")
logger.info(f"完成图片分析,共处理 {len(results)} 个批次")
return results
def analyze_images_sync(self,
images: List[Union[str, Path, PIL.Image.Image]],
prompt: str,
batch_size: int = 10) -> List[str]:
"""
同步版本的图片分析方法
"""
return asyncio.run(self.analyze_images(images, prompt, batch_size))

View File

@ -0,0 +1,111 @@
import ipaddress
from typing import Optional
from urllib.parse import urlparse
TRUSTED_OPENAI_COMPATIBLE_BASE_HOSTS = {
"api.openai.com",
"openrouter.ai",
"api.siliconflow.cn",
"dashscope.aliyuncs.com",
"api.deepseek.com",
"api.moonshot.cn",
"api.together.xyz",
"api.cohere.ai",
"generativelanguage.googleapis.com",
"open.bigmodel.cn",
"api.z.ai",
"ark.cn-beijing.volces.com",
"ark.cn-shanghai.volces.com",
}
TRUSTED_OPENAI_COMPATIBLE_BASE_SUFFIXES = (
".openai.azure.com",
".services.ai.azure.com",
".cognitiveservices.azure.com",
)
OPENAI_COMPATIBLE_BASE_URL_ERROR = (
"OpenAI-compatible base_url must be a valid http(s) URL without embedded credentials."
)
OPENAI_COMPATIBLE_BASE_URL_WARNING = (
"OpenAI-compatible base_url host '{host}' is not in the trusted provider list. "
"Only continue if you trust this endpoint, because it will receive the configured API key."
)
def _is_loopback_ollama_url(scheme: str, host: str, port: Optional[int]) -> bool:
if scheme not in {"http", "https"} or port != 11434:
return False
if host == "localhost":
return True
try:
return ipaddress.ip_address(host).is_loopback
except ValueError:
return False
def _is_well_formed_http_base_url(base_url: str) -> bool:
parsed = urlparse(str(base_url).strip())
if parsed.scheme not in {"http", "https"} or not parsed.hostname:
return False
if parsed.username or parsed.password:
return False
try:
parsed.port
except ValueError:
return False
return True
def is_trusted_openai_compatible_base_url(base_url: Optional[str]) -> bool:
if not base_url:
return True
parsed = urlparse(str(base_url).strip())
host = (parsed.hostname or "").rstrip(".").lower()
if not parsed.scheme or not host or parsed.username or parsed.password:
return False
try:
port = parsed.port
except ValueError:
return False
if _is_loopback_ollama_url(parsed.scheme, host, port):
return True
if parsed.scheme != "https":
return False
if host in TRUSTED_OPENAI_COMPATIBLE_BASE_HOSTS:
return True
return any(host.endswith(suffix) for suffix in TRUSTED_OPENAI_COMPATIBLE_BASE_SUFFIXES)
def openai_compatible_base_url_warning(base_url: Optional[str]) -> str:
if not base_url:
return ""
normalized = str(base_url).strip()
if is_trusted_openai_compatible_base_url(normalized) or not _is_well_formed_http_base_url(normalized):
return ""
parsed = urlparse(normalized)
host = (parsed.hostname or "").rstrip(".").lower()
return OPENAI_COMPATIBLE_BASE_URL_WARNING.format(host=host)
def validate_openai_compatible_base_url(base_url: Optional[str]) -> Optional[str]:
if not base_url:
return None
normalized = str(base_url).strip()
if is_trusted_openai_compatible_base_url(normalized):
return normalized
if _is_well_formed_http_base_url(normalized):
return normalized
raise ValueError(OPENAI_COMPATIBLE_BASE_URL_ERROR)

View File

@ -1,10 +1,12 @@
[app] [app]
project_version="0.7.8" project_version="0.8.4"
# LLM API 超时配置(秒) # LLM API 超时配置(秒)
llm_vision_timeout = 120 # 视觉模型基础超时时间 llm_vision_timeout = 120 # 视觉模型基础超时时间
llm_text_timeout = 180 # 文本模型基础超时时间(解说文案生成等复杂任务需要更长时间) llm_text_timeout = 180 # 文本模型基础超时时间(解说文案生成等复杂任务需要更长时间)
llm_max_retries = 3 # API 重试次数 llm_max_retries = 3 # API 重试次数
subtitle_translate_batch_size = 20 # 字幕翻译每批处理的字幕条数
subtitle_translate_max_workers = 3 # 字幕翻译最大并发批次数
########################################## ##########################################
# 🚀 LLM 配置 - 使用 OpenAI 兼容统一接口 # 🚀 LLM 配置 - 使用 OpenAI 兼容统一接口
@ -24,12 +26,22 @@
# - SiliconFlow: siliconflow/Qwen/Qwen2.5-VL-32B-Instruct # - SiliconFlow: siliconflow/Qwen/Qwen2.5-VL-32B-Instruct
vision_openai_model_name = "Qwen/Qwen3.5-122B-A10B" vision_openai_model_name = "Qwen/Qwen3.5-122B-A10B"
vision_openai_api_key = "" # 填入对应 provider 的 API key vision_openai_api_key = "" # 填入对应 provider 的 API key
vision_openai_base_url = "https://api.siliconflow.cn/v1" # 可选:自定义 API base URL(官方 OpenAI 可留空) vision_openai_base_url = "https://api.siliconflow.cn/v1" # 可选:自定义 API base URL;界面会提示 API key 将发送到对应端点
vision_openai_temperature = 1.0 vision_openai_temperature = 1.0
vision_openai_top_p = 0.95 vision_openai_top_p = 0.95
vision_openai_max_tokens = 65536 vision_openai_max_tokens = 65536
vision_openai_thinking_level = "auto" # auto/off/low/medium/high vision_openai_thinking_level = "auto" # auto/off/low/medium/high
# ===== 可选TwelveLabs Pegasus 视频理解 =====
# 将 vision_llm_provider 改为 "twelvelabs" 即可启用(默认仍为 openai不影响现有行为
# Pegasus 是原生的视频理解模型,会把每批关键帧拼成短片后整体理解,更擅长把握镜头内的
# 动作、时序与高光片段,从而生成更贴合画面的解说。需要本地可用的 ffmpeg。
# 免费 API Key 获取地址https://twelvelabs.io (有较充裕的免费额度)
# vision_llm_provider = "twelvelabs"
vision_twelvelabs_api_key = "" # 填入 TwelveLabs API Key
vision_twelvelabs_model_name = "pegasus1.5" # pegasus1.5 / pegasus1.2
vision_twelvelabs_max_tokens = 1024 # Pegasus 1.5 有效区间 512-98304
# ===== 文本模型配置 ===== # ===== 文本模型配置 =====
text_llm_provider = "openai" text_llm_provider = "openai"
@ -41,9 +53,10 @@
# - Qwen: qwen/qwen-plus, qwen/qwen-turbo # - Qwen: qwen/qwen-plus, qwen/qwen-turbo
# - SiliconFlow: siliconflow/deepseek-ai/DeepSeek-R1 # - SiliconFlow: siliconflow/deepseek-ai/DeepSeek-R1
# - Moonshot: moonshot/moonshot-v1-8k # - 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_api_key = "" # 填入对应 provider 的 API key
text_openai_base_url = "https://api.siliconflow.cn/v1" # 可选:自定义 API base URL(官方 OpenAI 可留空) text_openai_base_url = "https://api.siliconflow.cn/v1" # 可选:自定义 API base URL;界面会提示 API key 将发送到对应端点
text_openai_temperature = 1.0 text_openai_temperature = 1.0
text_openai_top_p = 0.95 text_openai_top_p = 0.95
text_openai_max_tokens = 65536 text_openai_max_tokens = 65536
@ -55,6 +68,20 @@
tavily_search_depth = "basic" # basic / advanced / fast / ultra-fast tavily_search_depth = "basic" # basic / advanced / fast / ultra-fast
tavily_max_results = 5 tavily_max_results = 5
# ===== 可选Sonilo AI 配乐BGM/ AI 音效SFX=====
# 配乐:在 WebUI 背景音乐来源中选择 "AI 生成配乐Sonilo" 即可启用(默认关闭,不影响现有 BGM 逻辑)。
# 启用后会将合成完成的视频(未加 BGM上传到 Sonilo API根据画面内容与剪辑节奏生成配乐
# 生成的音乐已获授权、可商用(以条款为准)。视频时长上限 6 分钟,生成失败时自动回退到随机背景音乐。
# 音效:在 WebUI 音频设置中勾选 "AI 音效Sonilo" 即可启用(默认关闭)。
# 启用后会将合成完成的视频上传到 Sonilo API根据画面内容生成音效并混在现有音轨之下解说不受影响
# 生成的音效为免版税素材。视频时长上限 3 分钟,生成失败时自动跳过音效。
sonilo_api_key = "" # 获取地址https://sonilo.com配乐与音效共用
# sonilo_base_url = "https://api.sonilo.com"
# sonilo_timeout_seconds = 600 # 生成接口读超时 / 音效任务等待上限(秒)
# sonilo_bgm_prompt = "" # 可选:配乐风格提示,留空则完全根据画面生成
# sonilo_sfx_prompt = "" # 可选:音效风格提示,留空则完全根据画面生成
# sonilo_sfx_volume = 0.6 # 音效混入原声之下的音量,范围 (0, 2],默认 0.6
# ===== API Keys 参考 ===== # ===== API Keys 参考 =====
# 主流 LLM Providers API Key 获取地址: # 主流 LLM Providers API Key 获取地址:
# #
@ -117,6 +144,7 @@
# backend = "local" 使用本地 FunASR-Pack APIbackend = "firered" 使用本地 FireRedASR2-AED-Pack APIbackend = "bailian" 使用阿里百炼在线 fun-asr # backend = "local" 使用本地 FunASR-Pack APIbackend = "firered" 使用本地 FireRedASR2-AED-Pack APIbackend = "bailian" 使用阿里百炼在线 fun-asr
auto_transcribe_enabled = false auto_transcribe_enabled = false
backend = "local" backend = "local"
# 支持填写服务根地址、完整 /asr 地址,或 OpenAI-compatible /v1 地址
api_url = "http://127.0.0.1:7860" api_url = "http://127.0.0.1:7860"
firered_api_url = "http://127.0.0.1:7867" firered_api_url = "http://127.0.0.1:7867"
hotword = "" hotword = ""
@ -147,40 +175,51 @@
num_beams = 3 num_beams = 3
repetition_penalty = 10.0 repetition_penalty = 10.0
[indextts_macos]
# IndexTTS-1.5 macOS MLX Pack 语音克隆配置(仅适用于 Apple Silicon
# 参考音频会通过 POST /v1/audio/speech/upload 上传
api_url = "http://127.0.0.1:7866"
reference_audio_source = "resource"
# reference_audio = "/path/to/reference_audio.wav"
speed = 1.0
# seed = 42
max_mel_tokens = 800
max_text_tokens_per_segment = 120
interval_silence = 200
temperature = 1.0
top_p = 0.8
top_k = 30
repetition_penalty = 10.0
segment_overlap_ms = 50
[indextts2] [indextts2]
# IndexTTS-2 语音克隆配置 # IndexTTS-2 MLX Pack 语音克隆配置
# 支持 IndexTTS2-Pack FastAPI 接口POST /tts # 参考音频会通过 POST /v1/audio/speech/upload 上传;可填写服务根地址或完整接口地址
api_url = "http://192.168.3.6:7863/tts" api_url = "http://127.0.0.1:7860"
# 默认参考音频(可选),音色列表复用 IndexTTS-1.5 的资源目录 # 默认参考音频(可选),音色列表复用 IndexTTS-1.5 的资源目录
reference_audio_source = "resource" reference_audio_source = "resource"
# reference_audio = "/path/to/reference_audio.wav" # reference_audio = "/path/to/reference_audio.wav"
# 情感控制speaker / audio / vector / text # 留空时保留参考音频情绪;支持单个情绪或混合权重,例如 happy:0.7,calm:0.3
emotion_mode = "speaker" emotion = ""
emotion_audio = "" emo_alpha = 0.6
emotion_alpha = 0.65 speed = 1.0
emotion_text = "" # 可选:设置固定随机种子以复现生成结果
use_random = false # seed = 20260713
max_text_tokens_per_segment = 120
# 8 维情感向量顺序happy, angry, sad, afraid, disgusted, melancholic, surprised, calm
vec_happy = 0.0
vec_angry = 0.0
vec_sad = 0.0
vec_afraid = 0.0
vec_disgusted = 0.0
vec_melancholic = 0.0
vec_surprised = 0.0
vec_calm = 0.8
# 高级生成参数 # 高级生成参数
max_text_tokens_per_segment = 120
interval_silence = 200
temperature = 0.8 temperature = 0.8
top_p = 0.8 top_p = 0.8
top_k = 30 top_k = 30
num_beams = 3
repetition_penalty = 10.0 repetition_penalty = 10.0
max_mel_tokens = 1500 max_mel_tokens = 1500
diffusion_steps = 25
cfg_rate = 0.7
segment_overlap_ms = 50
[omnivoice] [omnivoice]
# OmniVoice-Pack 语音合成配置 # OmniVoice-Pack 语音合成配置
@ -206,12 +245,45 @@
postprocess_output = true postprocess_output = true
preprocess_prompt = 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] [doubaotts]
# 豆包语音 TTS 配置 # 豆包语音 TTS 配置
# 新版配置优先填写 API Key旧版 appid/token 配置仍兼容
# 申请流程: # 申请流程:
# 1. 打开 https://console.volcengine.com/iam/keymanage 新建 Access Key 和 Secret Key # 1. 打开火山引擎豆包语音控制台
# 2. 打开 https://www.volcengine.com/product/voice-tech 点击立即使用 # 2. 进入 API Key 管理并创建 API Key
# 3. 在 API 服务中心找到音频生成下面的语音合成,获取 APPID 和 Token # 3. 确认已开通豆包语音合成服务
api_key = ""
# 旧版配置(兼容保留)
ak = "" ak = ""
sk = "" sk = ""
appid = "" appid = ""

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 916 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 696 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 468 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 300 KiB

View File

@ -1,965 +0,0 @@
Name: af-ZA-AdriNeural
Gender: Female
Name: af-ZA-WillemNeural
Gender: Male
Name: am-ET-AmehaNeural
Gender: Male
Name: am-ET-MekdesNeural
Gender: Female
Name: ar-AE-FatimaNeural
Gender: Female
Name: ar-AE-HamdanNeural
Gender: Male
Name: ar-BH-AliNeural
Gender: Male
Name: ar-BH-LailaNeural
Gender: Female
Name: ar-DZ-AminaNeural
Gender: Female
Name: ar-DZ-IsmaelNeural
Gender: Male
Name: ar-EG-SalmaNeural
Gender: Female
Name: ar-EG-ShakirNeural
Gender: Male
Name: ar-IQ-BasselNeural
Gender: Male
Name: ar-IQ-RanaNeural
Gender: Female
Name: ar-JO-SanaNeural
Gender: Female
Name: ar-JO-TaimNeural
Gender: Male
Name: ar-KW-FahedNeural
Gender: Male
Name: ar-KW-NouraNeural
Gender: Female
Name: ar-LB-LaylaNeural
Gender: Female
Name: ar-LB-RamiNeural
Gender: Male
Name: ar-LY-ImanNeural
Gender: Female
Name: ar-LY-OmarNeural
Gender: Male
Name: ar-MA-JamalNeural
Gender: Male
Name: ar-MA-MounaNeural
Gender: Female
Name: ar-OM-AbdullahNeural
Gender: Male
Name: ar-OM-AyshaNeural
Gender: Female
Name: ar-QA-AmalNeural
Gender: Female
Name: ar-QA-MoazNeural
Gender: Male
Name: ar-SA-HamedNeural
Gender: Male
Name: ar-SA-ZariyahNeural
Gender: Female
Name: ar-SY-AmanyNeural
Gender: Female
Name: ar-SY-LaithNeural
Gender: Male
Name: ar-TN-HediNeural
Gender: Male
Name: ar-TN-ReemNeural
Gender: Female
Name: ar-YE-MaryamNeural
Gender: Female
Name: ar-YE-SalehNeural
Gender: Male
Name: az-AZ-BabekNeural
Gender: Male
Name: az-AZ-BanuNeural
Gender: Female
Name: bg-BG-BorislavNeural
Gender: Male
Name: bg-BG-KalinaNeural
Gender: Female
Name: bn-BD-NabanitaNeural
Gender: Female
Name: bn-BD-PradeepNeural
Gender: Male
Name: bn-IN-BashkarNeural
Gender: Male
Name: bn-IN-TanishaaNeural
Gender: Female
Name: bs-BA-GoranNeural
Gender: Male
Name: bs-BA-VesnaNeural
Gender: Female
Name: ca-ES-EnricNeural
Gender: Male
Name: ca-ES-JoanaNeural
Gender: Female
Name: cs-CZ-AntoninNeural
Gender: Male
Name: cs-CZ-VlastaNeural
Gender: Female
Name: cy-GB-AledNeural
Gender: Male
Name: cy-GB-NiaNeural
Gender: Female
Name: da-DK-ChristelNeural
Gender: Female
Name: da-DK-JeppeNeural
Gender: Male
Name: de-AT-IngridNeural
Gender: Female
Name: de-AT-JonasNeural
Gender: Male
Name: de-CH-JanNeural
Gender: Male
Name: de-CH-LeniNeural
Gender: Female
Name: de-DE-AmalaNeural
Gender: Female
Name: de-DE-ConradNeural
Gender: Male
Name: de-DE-FlorianMultilingualNeural
Gender: Male
Name: de-DE-KatjaNeural
Gender: Female
Name: de-DE-KillianNeural
Gender: Male
Name: de-DE-SeraphinaMultilingualNeural
Gender: Female
Name: el-GR-AthinaNeural
Gender: Female
Name: el-GR-NestorasNeural
Gender: Male
Name: en-AU-NatashaNeural
Gender: Female
Name: en-AU-WilliamMultilingualNeural
Gender: Male
Name: en-CA-ClaraNeural
Gender: Female
Name: en-CA-LiamNeural
Gender: Male
Name: en-GB-LibbyNeural
Gender: Female
Name: en-GB-MaisieNeural
Gender: Female
Name: en-GB-RyanNeural
Gender: Male
Name: en-GB-SoniaNeural
Gender: Female
Name: en-GB-ThomasNeural
Gender: Male
Name: en-HK-SamNeural
Gender: Male
Name: en-HK-YanNeural
Gender: Female
Name: en-IE-ConnorNeural
Gender: Male
Name: en-IE-EmilyNeural
Gender: Female
Name: en-IN-NeerjaExpressiveNeural
Gender: Female
Name: en-IN-NeerjaNeural
Gender: Female
Name: en-IN-PrabhatNeural
Gender: Male
Name: en-KE-AsiliaNeural
Gender: Female
Name: en-KE-ChilembaNeural
Gender: Male
Name: en-NG-AbeoNeural
Gender: Male
Name: en-NG-EzinneNeural
Gender: Female
Name: en-NZ-MitchellNeural
Gender: Male
Name: en-NZ-MollyNeural
Gender: Female
Name: en-PH-JamesNeural
Gender: Male
Name: en-PH-RosaNeural
Gender: Female
Name: en-SG-LunaNeural
Gender: Female
Name: en-SG-WayneNeural
Gender: Male
Name: en-TZ-ElimuNeural
Gender: Male
Name: en-TZ-ImaniNeural
Gender: Female
Name: en-US-AnaNeural
Gender: Female
Name: en-US-AndrewNeural
Gender: Male
Name: en-US-AndrewMultilingualNeural
Gender: Male
Name: en-US-AriaNeural
Gender: Female
Name: en-US-AvaNeural
Gender: Female
Name: en-US-AvaMultilingualNeural
Gender: Female
Name: en-US-BrianNeural
Gender: Male
Name: en-US-BrianMultilingualNeural
Gender: Male
Name: en-US-ChristopherNeural
Gender: Male
Name: en-US-EmmaNeural
Gender: Female
Name: en-US-EmmaMultilingualNeural
Gender: Female
Name: en-US-EricNeural
Gender: Male
Name: en-US-GuyNeural
Gender: Male
Name: en-US-JennyNeural
Gender: Female
Name: en-US-MichelleNeural
Gender: Female
Name: en-US-RogerNeural
Gender: Male
Name: en-US-SteffanNeural
Gender: Male
Name: en-ZA-LeahNeural
Gender: Female
Name: en-ZA-LukeNeural
Gender: Male
Name: es-AR-ElenaNeural
Gender: Female
Name: es-AR-TomasNeural
Gender: Male
Name: es-BO-MarceloNeural
Gender: Male
Name: es-BO-SofiaNeural
Gender: Female
Name: es-CL-CatalinaNeural
Gender: Female
Name: es-CL-LorenzoNeural
Gender: Male
Name: es-CO-GonzaloNeural
Gender: Male
Name: es-CO-SalomeNeural
Gender: Female
Name: es-CR-JuanNeural
Gender: Male
Name: es-CR-MariaNeural
Gender: Female
Name: es-CU-BelkysNeural
Gender: Female
Name: es-CU-ManuelNeural
Gender: Male
Name: es-DO-EmilioNeural
Gender: Male
Name: es-DO-RamonaNeural
Gender: Female
Name: es-EC-AndreaNeural
Gender: Female
Name: es-EC-LuisNeural
Gender: Male
Name: es-ES-AlvaroNeural
Gender: Male
Name: es-ES-ElviraNeural
Gender: Female
Name: es-ES-XimenaNeural
Gender: Female
Name: es-GQ-JavierNeural
Gender: Male
Name: es-GQ-TeresaNeural
Gender: Female
Name: es-GT-AndresNeural
Gender: Male
Name: es-GT-MartaNeural
Gender: Female
Name: es-HN-CarlosNeural
Gender: Male
Name: es-HN-KarlaNeural
Gender: Female
Name: es-MX-DaliaNeural
Gender: Female
Name: es-MX-JorgeNeural
Gender: Male
Name: es-NI-FedericoNeural
Gender: Male
Name: es-NI-YolandaNeural
Gender: Female
Name: es-PA-MargaritaNeural
Gender: Female
Name: es-PA-RobertoNeural
Gender: Male
Name: es-PE-AlexNeural
Gender: Male
Name: es-PE-CamilaNeural
Gender: Female
Name: es-PR-KarinaNeural
Gender: Female
Name: es-PR-VictorNeural
Gender: Male
Name: es-PY-MarioNeural
Gender: Male
Name: es-PY-TaniaNeural
Gender: Female
Name: es-SV-LorenaNeural
Gender: Female
Name: es-SV-RodrigoNeural
Gender: Male
Name: es-US-AlonsoNeural
Gender: Male
Name: es-US-PalomaNeural
Gender: Female
Name: es-UY-MateoNeural
Gender: Male
Name: es-UY-ValentinaNeural
Gender: Female
Name: es-VE-PaolaNeural
Gender: Female
Name: es-VE-SebastianNeural
Gender: Male
Name: et-EE-AnuNeural
Gender: Female
Name: et-EE-KertNeural
Gender: Male
Name: fa-IR-DilaraNeural
Gender: Female
Name: fa-IR-FaridNeural
Gender: Male
Name: fi-FI-HarriNeural
Gender: Male
Name: fi-FI-NooraNeural
Gender: Female
Name: fil-PH-AngeloNeural
Gender: Male
Name: fil-PH-BlessicaNeural
Gender: Female
Name: fr-BE-CharlineNeural
Gender: Female
Name: fr-BE-GerardNeural
Gender: Male
Name: fr-CA-AntoineNeural
Gender: Male
Name: fr-CA-JeanNeural
Gender: Male
Name: fr-CA-SylvieNeural
Gender: Female
Name: fr-CA-ThierryNeural
Gender: Male
Name: fr-CH-ArianeNeural
Gender: Female
Name: fr-CH-FabriceNeural
Gender: Male
Name: fr-FR-DeniseNeural
Gender: Female
Name: fr-FR-EloiseNeural
Gender: Female
Name: fr-FR-HenriNeural
Gender: Male
Name: fr-FR-RemyMultilingualNeural
Gender: Male
Name: fr-FR-VivienneMultilingualNeural
Gender: Female
Name: ga-IE-ColmNeural
Gender: Male
Name: ga-IE-OrlaNeural
Gender: Female
Name: gl-ES-RoiNeural
Gender: Male
Name: gl-ES-SabelaNeural
Gender: Female
Name: gu-IN-DhwaniNeural
Gender: Female
Name: gu-IN-NiranjanNeural
Gender: Male
Name: he-IL-AvriNeural
Gender: Male
Name: he-IL-HilaNeural
Gender: Female
Name: hi-IN-MadhurNeural
Gender: Male
Name: hi-IN-SwaraNeural
Gender: Female
Name: hr-HR-GabrijelaNeural
Gender: Female
Name: hr-HR-SreckoNeural
Gender: Male
Name: hu-HU-NoemiNeural
Gender: Female
Name: hu-HU-TamasNeural
Gender: Male
Name: id-ID-ArdiNeural
Gender: Male
Name: id-ID-GadisNeural
Gender: Female
Name: is-IS-GudrunNeural
Gender: Female
Name: is-IS-GunnarNeural
Gender: Male
Name: it-IT-DiegoNeural
Gender: Male
Name: it-IT-ElsaNeural
Gender: Female
Name: it-IT-GiuseppeMultilingualNeural
Gender: Male
Name: it-IT-IsabellaNeural
Gender: Female
Name: iu-Cans-CA-SiqiniqNeural
Gender: Female
Name: iu-Cans-CA-TaqqiqNeural
Gender: Male
Name: iu-Latn-CA-SiqiniqNeural
Gender: Female
Name: iu-Latn-CA-TaqqiqNeural
Gender: Male
Name: ja-JP-KeitaNeural
Gender: Male
Name: ja-JP-NanamiNeural
Gender: Female
Name: jv-ID-DimasNeural
Gender: Male
Name: jv-ID-SitiNeural
Gender: Female
Name: ka-GE-EkaNeural
Gender: Female
Name: ka-GE-GiorgiNeural
Gender: Male
Name: kk-KZ-AigulNeural
Gender: Female
Name: kk-KZ-DauletNeural
Gender: Male
Name: km-KH-PisethNeural
Gender: Male
Name: km-KH-SreymomNeural
Gender: Female
Name: kn-IN-GaganNeural
Gender: Male
Name: kn-IN-SapnaNeural
Gender: Female
Name: ko-KR-HyunsuMultilingualNeural
Gender: Male
Name: ko-KR-InJoonNeural
Gender: Male
Name: ko-KR-SunHiNeural
Gender: Female
Name: lo-LA-ChanthavongNeural
Gender: Male
Name: lo-LA-KeomanyNeural
Gender: Female
Name: lt-LT-LeonasNeural
Gender: Male
Name: lt-LT-OnaNeural
Gender: Female
Name: lv-LV-EveritaNeural
Gender: Female
Name: lv-LV-NilsNeural
Gender: Male
Name: mk-MK-AleksandarNeural
Gender: Male
Name: mk-MK-MarijaNeural
Gender: Female
Name: ml-IN-MidhunNeural
Gender: Male
Name: ml-IN-SobhanaNeural
Gender: Female
Name: mn-MN-BataaNeural
Gender: Male
Name: mn-MN-YesuiNeural
Gender: Female
Name: mr-IN-AarohiNeural
Gender: Female
Name: mr-IN-ManoharNeural
Gender: Male
Name: ms-MY-OsmanNeural
Gender: Male
Name: ms-MY-YasminNeural
Gender: Female
Name: mt-MT-GraceNeural
Gender: Female
Name: mt-MT-JosephNeural
Gender: Male
Name: my-MM-NilarNeural
Gender: Female
Name: my-MM-ThihaNeural
Gender: Male
Name: nb-NO-FinnNeural
Gender: Male
Name: nb-NO-PernilleNeural
Gender: Female
Name: ne-NP-HemkalaNeural
Gender: Female
Name: ne-NP-SagarNeural
Gender: Male
Name: nl-BE-ArnaudNeural
Gender: Male
Name: nl-BE-DenaNeural
Gender: Female
Name: nl-NL-ColetteNeural
Gender: Female
Name: nl-NL-FennaNeural
Gender: Female
Name: nl-NL-MaartenNeural
Gender: Male
Name: pl-PL-MarekNeural
Gender: Male
Name: pl-PL-ZofiaNeural
Gender: Female
Name: ps-AF-GulNawazNeural
Gender: Male
Name: ps-AF-LatifaNeural
Gender: Female
Name: pt-BR-AntonioNeural
Gender: Male
Name: pt-BR-FranciscaNeural
Gender: Female
Name: pt-BR-ThalitaMultilingualNeural
Gender: Female
Name: pt-PT-DuarteNeural
Gender: Male
Name: pt-PT-RaquelNeural
Gender: Female
Name: ro-RO-AlinaNeural
Gender: Female
Name: ro-RO-EmilNeural
Gender: Male
Name: ru-RU-DmitryNeural
Gender: Male
Name: ru-RU-SvetlanaNeural
Gender: Female
Name: si-LK-SameeraNeural
Gender: Male
Name: si-LK-ThiliniNeural
Gender: Female
Name: sk-SK-LukasNeural
Gender: Male
Name: sk-SK-ViktoriaNeural
Gender: Female
Name: sl-SI-PetraNeural
Gender: Female
Name: sl-SI-RokNeural
Gender: Male
Name: so-SO-MuuseNeural
Gender: Male
Name: so-SO-UbaxNeural
Gender: Female
Name: sq-AL-AnilaNeural
Gender: Female
Name: sq-AL-IlirNeural
Gender: Male
Name: sr-RS-NicholasNeural
Gender: Male
Name: sr-RS-SophieNeural
Gender: Female
Name: su-ID-JajangNeural
Gender: Male
Name: su-ID-TutiNeural
Gender: Female
Name: sv-SE-MattiasNeural
Gender: Male
Name: sv-SE-SofieNeural
Gender: Female
Name: sw-KE-RafikiNeural
Gender: Male
Name: sw-KE-ZuriNeural
Gender: Female
Name: sw-TZ-DaudiNeural
Gender: Male
Name: sw-TZ-RehemaNeural
Gender: Female
Name: ta-IN-PallaviNeural
Gender: Female
Name: ta-IN-ValluvarNeural
Gender: Male
Name: ta-LK-KumarNeural
Gender: Male
Name: ta-LK-SaranyaNeural
Gender: Female
Name: ta-MY-KaniNeural
Gender: Female
Name: ta-MY-SuryaNeural
Gender: Male
Name: ta-SG-AnbuNeural
Gender: Male
Name: ta-SG-VenbaNeural
Gender: Female
Name: te-IN-MohanNeural
Gender: Male
Name: te-IN-ShrutiNeural
Gender: Female
Name: th-TH-NiwatNeural
Gender: Male
Name: th-TH-PremwadeeNeural
Gender: Female
Name: tr-TR-AhmetNeural
Gender: Male
Name: tr-TR-EmelNeural
Gender: Female
Name: uk-UA-OstapNeural
Gender: Male
Name: uk-UA-PolinaNeural
Gender: Female
Name: ur-IN-GulNeural
Gender: Female
Name: ur-IN-SalmanNeural
Gender: Male
Name: ur-PK-AsadNeural
Gender: Male
Name: ur-PK-UzmaNeural
Gender: Female
Name: uz-UZ-MadinaNeural
Gender: Female
Name: uz-UZ-SardorNeural
Gender: Male
Name: vi-VN-HoaiMyNeural
Gender: Female
Name: vi-VN-NamMinhNeural
Gender: Male
Name: zh-CN-XiaoxiaoNeural
Gender: Female
Name: zh-CN-XiaoyiNeural
Gender: Female
Name: zh-CN-YunjianNeural
Gender: Male
Name: zh-CN-YunxiNeural
Gender: Male
Name: zh-CN-YunxiaNeural
Gender: Male
Name: zh-CN-YunyangNeural
Gender: Male
Name: zh-CN-liaoning-XiaobeiNeural
Gender: Female
Name: zh-CN-shaanxi-XiaoniNeural
Gender: Female
Name: zh-HK-HiuGaaiNeural
Gender: Female
Name: zh-HK-HiuMaanNeural
Gender: Female
Name: zh-HK-WanLungNeural
Gender: Male
Name: zh-TW-HsiaoChenNeural
Gender: Female
Name: zh-TW-HsiaoYuNeural
Gender: Female
Name: zh-TW-YunJheNeural
Gender: Male
Name: zu-ZA-ThandoNeural
Gender: Female
Name: zu-ZA-ThembaNeural
Gender: Male

View File

@ -1 +1 @@
0.8.2 0.8.7

50
pyproject.toml Normal file
View File

@ -0,0 +1,50 @@
[project]
name = "narratoai"
version = "0.8.4"
description = "An all-in-one AI-powered tool for film commentary and automated video editing."
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"requests>=2.32.0",
"moviepy==2.1.1",
"edge-tts==7.2.7",
"streamlit==1.56.0",
"watchdog==6.0.0",
"loguru>=0.7.3",
"tomli>=2.2.1",
"tomli-w>=1.0.0",
"pydub==0.25.1",
"pysrt==1.1.2",
"openai>=1.77.0",
"google-generativeai>=0.8.5",
"azure-cognitiveservices-speech>=1.37.0",
"tencentcloud-sdk-python>=3.0.1200",
"dashscope>=1.24.6",
"Pillow>=10.3.0",
"tqdm>=4.66.6",
"tenacity>=9.0.0",
]
[project.optional-dependencies]
twelvelabs = [
"twelvelabs>=1.2.8",
]
local-asr = [
"faster-whisper>=1.0.1",
]
opencv = [
"opencv-python>=4.11.0.86",
]
cuda = [
"torch>=2.0.0",
"torchvision>=0.15.0",
"torchaudio>=2.0.0",
]
[dependency-groups]
dev = [
"pytest>=8.0.0",
]
[tool.uv]
package = false

View File

@ -25,6 +25,9 @@ tqdm>=4.66.6
tenacity>=9.0.0 tenacity>=9.0.0
# 可选依赖(根据功能需要) # 可选依赖(根据功能需要)
# 如果使用 TwelveLabs Pegasus 视频理解作为视觉提供商,取消注释下面的行
# twelvelabs>=1.2.8
# 如果需要本地语音识别,取消注释下面的行 # 如果需要本地语音识别,取消注释下面的行
# faster-whisper>=1.0.1 # faster-whisper>=1.0.1

View File

@ -0,0 +1,163 @@
import base64
import json
import os
import tempfile
import unittest
from unittest import mock
from app.services import sonilo
def _ndjson_lines(*events):
return [json.dumps(event) for event in events]
class ConsumeNdjsonStreamTests(unittest.TestCase):
def test_returns_first_stream_audio_grouped_by_index(self):
lines = _ndjson_lines(
{"type": "audio_chunk", "stream_index": 0, "data": base64.b64encode(b"ab").decode()},
{"type": "audio_chunk", "stream_index": 1, "data": base64.b64encode(b"zz").decode()},
{"type": "audio_chunk", "stream_index": 0, "data": base64.b64encode(b"cd").decode()},
{"type": "title", "data": "some title"},
{"type": "complete"},
)
self.assertEqual(b"abcd", sonilo._consume_ndjson_stream(lines))
def test_ignores_blank_and_unparseable_lines(self):
lines = [
"",
" ",
"not json",
json.dumps({"type": "audio_chunk", "stream_index": 0, "data": base64.b64encode(b"ok").decode()}),
json.dumps({"type": "complete"}),
]
self.assertEqual(b"ok", sonilo._consume_ndjson_stream(lines))
def test_error_event_raises(self):
lines = _ndjson_lines(
{"type": "audio_chunk", "stream_index": 0, "data": base64.b64encode(b"ab").decode()},
{"type": "error", "message": "boom"},
)
with self.assertRaises(sonilo.SoniloError):
sonilo._consume_ndjson_stream(lines)
def test_missing_complete_event_raises(self):
lines = _ndjson_lines(
{"type": "audio_chunk", "stream_index": 0, "data": base64.b64encode(b"ab").decode()},
)
with self.assertRaises(sonilo.SoniloError):
sonilo._consume_ndjson_stream(lines)
def test_complete_without_audio_raises(self):
lines = _ndjson_lines({"type": "complete"})
with self.assertRaises(sonilo.SoniloError):
sonilo._consume_ndjson_stream(lines)
class GenerateBgmTests(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_bgm.m4a")
def test_returns_empty_without_api_key(self):
with mock.patch.object(sonilo, "get_api_key", return_value=""):
self.assertEqual("", sonilo.generate_bgm(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_bgm(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=361.0), \
mock.patch.object(sonilo, "_request_video_to_music") as request_mock:
self.assertEqual("", sonilo.generate_bgm(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_music", return_value=b"audio-bytes"):
self.assertEqual(self.save_path, sonilo.generate_bgm(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_music",
side_effect=sonilo.SoniloError("timeout"),
):
self.assertEqual("", sonilo.generate_bgm(self.video_path, self.save_path))
self.assertFalse(os.path.exists(self.save_path))
class ResolveBgmPathTests(unittest.TestCase):
"""任务层的 BGM 解析Sonilo 模式失败时回退到现有 BGM 逻辑。"""
def _make_params(self, bgm_type, bgm_file=""):
params = mock.Mock()
params.bgm_type = bgm_type
params.bgm_file = bgm_file
return params
def test_non_sonilo_type_uses_existing_logic(self):
from app.services import task
params = self._make_params("custom", "/tmp/some_bgm.mp3")
with mock.patch.object(task.utils, "get_bgm_file", return_value="/tmp/some_bgm.mp3") as get_bgm, \
mock.patch.object(task.sonilo, "generate_bgm") as generate_bgm:
result = task._resolve_bgm_path("task-id", params, "/tmp/combined.mp4")
self.assertEqual("/tmp/some_bgm.mp3", result)
get_bgm.assert_called_once_with(bgm_type="custom", bgm_file="/tmp/some_bgm.mp3")
generate_bgm.assert_not_called()
def test_sonilo_type_uses_generated_bgm(self):
from app.services import task
params = self._make_params("sonilo")
with mock.patch.object(task.utils, "task_dir", return_value="/tmp/task-id"), \
mock.patch.object(
task.sonilo, "generate_bgm", return_value="/tmp/task-id/sonilo_bgm.m4a"
) as generate_bgm, \
mock.patch.object(task.utils, "get_bgm_file") as get_bgm:
result = task._resolve_bgm_path("task-id", params, "/tmp/combined.mp4")
self.assertEqual("/tmp/task-id/sonilo_bgm.m4a", result)
generate_bgm.assert_called_once_with(
"/tmp/combined.mp4", os.path.join("/tmp/task-id", "sonilo_bgm.m4a")
)
get_bgm.assert_not_called()
def test_sonilo_failure_falls_back_to_random_bgm(self):
from app.services import task
params = self._make_params("sonilo")
with mock.patch.object(task.utils, "task_dir", return_value="/tmp/task-id"), \
mock.patch.object(task.sonilo, "generate_bgm", return_value=""), \
mock.patch.object(
task.utils, "get_bgm_file", return_value="/resource/songs/output000.mp3"
) as get_bgm:
result = task._resolve_bgm_path("task-id", params, "/tmp/combined.mp4")
self.assertEqual("/resource/songs/output000.mp3", result)
get_bgm.assert_called_once_with(bgm_type="random", bgm_file="")
if __name__ == "__main__":
unittest.main()

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

@ -0,0 +1,84 @@
"""Regression coverage for Streamlit widget state initialization."""
import ast
from pathlib import Path
import pytest
PROJECT_ROOT = Path(__file__).parents[1]
def _attribute_name(node: ast.expr) -> str | None:
if isinstance(node, ast.Name):
return node.id
if isinstance(node, ast.Attribute):
parent = _attribute_name(node.value)
return f"{parent}.{node.attr}" if parent else node.attr
return None
def _session_state_key(target: ast.expr) -> str | None:
if not isinstance(target, ast.Subscript):
return None
if _attribute_name(target.value) != "st.session_state":
return None
if isinstance(target.slice, ast.Constant) and isinstance(target.slice.value, str):
return target.slice.value
return None
@pytest.mark.parametrize(
("source_path", "function_name", "widget_key"),
[
(
"webui/components/audio_settings.py",
"render_bgm_settings",
"bgm_source_selection",
),
(
"webui/components/subtitle_settings.py",
"_render_subtitle_preview_image",
"subtitle_preview_orientation",
),
],
)
def test_state_initialized_widgets_do_not_also_declare_defaults(
source_path: str,
function_name: str,
widget_key: str,
):
"""Streamlit warns when a keyed widget has both a default and state value."""
module = ast.parse((PROJECT_ROOT / source_path).read_text(encoding="utf-8"))
function = next(
node
for node in module.body
if isinstance(node, ast.FunctionDef) and node.name == function_name
)
assigned_keys = {
key
for node in ast.walk(function)
if isinstance(node, ast.Assign)
for target in node.targets
if (key := _session_state_key(target)) is not None
}
assert widget_key in assigned_keys
widget_calls = []
for node in ast.walk(function):
if not isinstance(node, ast.Call):
continue
widget_name = _attribute_name(node.func)
keywords = {keyword.arg: keyword.value for keyword in node.keywords}
key_node = keywords.get("key")
if (
widget_name is not None
and widget_name.startswith("st.")
and isinstance(key_node, ast.Constant)
and key_node.value == widget_key
):
widget_calls.append(keywords)
assert widget_calls
assert all("default" not in keywords for keywords in widget_calls)

3031
uv.lock generated Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -9,12 +9,17 @@ from app.config.defaults import (
DEFAULT_OPENAI_COMPATIBLE_BASE_URL, DEFAULT_OPENAI_COMPATIBLE_BASE_URL,
DEFAULT_OPENAI_COMPATIBLE_PROVIDER, DEFAULT_OPENAI_COMPATIBLE_PROVIDER,
DEFAULT_TEXT_LLM_PROVIDER, DEFAULT_TEXT_LLM_PROVIDER,
DEFAULT_TEXT_OPENAI_FAST_MODEL_NAME,
DEFAULT_TEXT_OPENAI_MODEL_NAME, DEFAULT_TEXT_OPENAI_MODEL_NAME,
DEFAULT_VISION_LLM_PROVIDER, DEFAULT_VISION_LLM_PROVIDER,
DEFAULT_VISION_OPENAI_MODEL_NAME, DEFAULT_VISION_OPENAI_MODEL_NAME,
get_openai_compatible_ui_values, get_openai_compatible_ui_values,
normalize_openai_compatible_model_name as normalize_openai_compatible_model_id, normalize_openai_compatible_model_name as normalize_openai_compatible_model_id,
) )
from app.utils.openai_base_url_security import (
openai_compatible_base_url_warning,
validate_openai_compatible_base_url,
)
from app.utils import utils from app.utils import utils
from loguru import logger from loguru import logger
from app.services.llm.unified_service import UnifiedLLMService from app.services.llm.unified_service import UnifiedLLMService
@ -73,9 +78,20 @@ def validate_base_url(base_url: str, provider: str) -> tuple[bool, str]:
if not (base_url.startswith('http://') or base_url.startswith('https://')): if not (base_url.startswith('http://') or base_url.startswith('https://')):
return False, f"{provider} Base URL必须以http://或https://开头" return False, f"{provider} Base URL必须以http://或https://开头"
try:
validate_openai_compatible_base_url(base_url)
except ValueError as exc:
return False, f"{provider} Base URL格式无效: {exc}"
return True, "" return True, ""
def show_base_url_security_warning(base_url: str) -> None:
warning = openai_compatible_base_url_warning(base_url)
if warning:
st.warning(warning)
def validate_model_name(model_name: str, provider: str) -> tuple[bool, str]: def validate_model_name(model_name: str, provider: str) -> tuple[bool, str]:
"""验证模型名称""" """验证模型名称"""
if not model_name or not model_name.strip(): if not model_name or not model_name.strip():
@ -420,6 +436,7 @@ def test_vision_model_connection(api_key, base_url, model_name, provider, tr):
elif provider.lower() == 'gemini(openai)': elif provider.lower() == 'gemini(openai)':
# OpenAI兼容的Gemini代理测试 # OpenAI兼容的Gemini代理测试
try: try:
base_url = validate_openai_compatible_base_url(base_url)
headers = { headers = {
"Authorization": f"Bearer {api_key}", "Authorization": f"Bearer {api_key}",
"Content-Type": "application/json" "Content-Type": "application/json"
@ -444,6 +461,7 @@ def test_vision_model_connection(api_key, base_url, model_name, provider, tr):
else: else:
from openai import OpenAI from openai import OpenAI
try: try:
base_url = validate_openai_compatible_base_url(base_url)
client = OpenAI( client = OpenAI(
api_key=api_key, api_key=api_key,
base_url=base_url, base_url=base_url,
@ -493,9 +511,10 @@ def test_openai_compatible_vision_model(api_key: str, base_url: str, model_name:
from PIL import Image from PIL import Image
logger.debug( logger.debug(
f"OpenAI 兼容视觉模型连通性测试: model={model_name}, api_key={api_key[:10]}..., base_url={base_url}" f"OpenAI 兼容视觉模型连通性测试: model={model_name}, base_url={base_url}"
) )
base_url = validate_openai_compatible_base_url(base_url)
client = OpenAI( client = OpenAI(
api_key=api_key, api_key=api_key,
base_url=base_url or None, base_url=base_url or None,
@ -548,9 +567,10 @@ def test_openai_compatible_text_model(api_key: str, base_url: str, model_name: s
from openai import OpenAI from openai import OpenAI
logger.debug( logger.debug(
f"OpenAI 兼容文本模型连通性测试: model={model_name}, api_key={api_key[:10]}..., base_url={base_url}" f"OpenAI 兼容文本模型连通性测试: model={model_name}, base_url={base_url}"
) )
base_url = validate_openai_compatible_base_url(base_url)
client = OpenAI( client = OpenAI(
api_key=api_key, api_key=api_key,
base_url=base_url or None, base_url=base_url or None,
@ -653,6 +673,7 @@ def render_vision_llm_settings(tr):
if vision_base_required and not st_vision_base_url: if vision_base_required and not st_vision_base_url:
info_example = vision_placeholder or "https://your-openai-compatible-endpoint/v1" info_example = vision_placeholder or "https://your-openai-compatible-endpoint/v1"
st.info(tr("Please fill OpenAI compatible gateway").format(example=info_example)) st.info(tr("Please fill OpenAI compatible gateway").format(example=info_example))
show_base_url_security_warning(st_vision_base_url)
vision_generation_params = render_llm_generation_settings(tr, "vision") vision_generation_params = render_llm_generation_settings(tr, "vision")
@ -856,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_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_api_key = config.app.get("text_openai_api_key", "")
text_base_url = config.app.get("text_openai_base_url", DEFAULT_OPENAI_COMPATIBLE_BASE_URL) text_base_url = config.app.get("text_openai_base_url", DEFAULT_OPENAI_COMPATIBLE_BASE_URL)
@ -865,10 +890,14 @@ def render_text_llm_settings(tr):
DEFAULT_TEXT_OPENAI_MODEL_NAME, DEFAULT_TEXT_OPENAI_MODEL_NAME,
provider=DEFAULT_TEXT_LLM_PROVIDER, 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 selected_provider = DEFAULT_TEXT_LLM_PROVIDER
# 渲染配置输入框 # 渲染配置输入框
col1, col2 = st.columns([1, 2]) col1, col2, col3 = st.columns([1, 2, 2])
with col1: with col1:
render_openai_compatible_protocol_field( render_openai_compatible_protocol_field(
tr, tr,
@ -877,11 +906,13 @@ def render_text_llm_settings(tr):
) )
with col2: with col2:
model_name_input = st.text_input( reasoning_model_name_input = st.text_input(
tr("Text Model Name"), tr("High Reasoning Model Name"),
value=current_model, value=current_model,
help=( help=(
tr("Model Name Input Help") tr("High Reasoning Model Help")
+ "\n\n"
+ tr("Model Name Input Help")
+ "\n\n" + "\n\n"
+ "• Pro/zai-org/GLM-5\n" + "• Pro/zai-org/GLM-5\n"
+ "• deepseek/deepseek-chat\n" + "• deepseek/deepseek-chat\n"
@ -892,8 +923,24 @@ def render_text_llm_settings(tr):
key="text_model_input" 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( st_text_api_key = st.text_input(
tr("Text API Key"), tr("Text API Key"),
@ -923,6 +970,7 @@ def render_text_llm_settings(tr):
if text_base_required and not st_text_base_url: if text_base_required and not st_text_base_url:
info_example = text_placeholder or "https://your-openai-compatible-endpoint/v1" info_example = text_placeholder or "https://your-openai-compatible-endpoint/v1"
st.info(tr("Please fill OpenAI compatible gateway").format(example=info_example)) st.info(tr("Please fill OpenAI compatible gateway").format(example=info_example))
show_base_url_security_warning(st_text_base_url)
text_generation_params = render_llm_generation_settings(tr, "text") text_generation_params = render_llm_generation_settings(tr, "text")
@ -931,7 +979,7 @@ def render_text_llm_settings(tr):
test_errors = [] test_errors = []
if not st_text_api_key: if not st_text_api_key:
test_errors.append(tr("Please enter 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")) test_errors.append(tr("Please enter model name"))
if test_errors: if test_errors:
@ -940,17 +988,25 @@ def render_text_llm_settings(tr):
else: else:
with st.spinner(tr("Testing connection...")): with st.spinner(tr("Testing connection...")):
try: try:
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( success, message = test_openai_compatible_text_model(
api_key=st_text_api_key, api_key=st_text_api_key,
base_url=st_text_base_url, base_url=st_text_base_url,
model_name=st_text_model_name, model_name=target_model,
tr=tr tr=tr,
) )
if success: if success:
st.success(message) st.success(f"{label}: {message}")
else: else:
st.error(message) st.error(f"{label}: {message}")
except Exception as e: except Exception as e:
st.error(f"{tr('Connection test error')}: {str(e)}") st.error(f"{tr('Connection test error')}: {str(e)}")
logger.error(f"OpenAI 兼容 文案生成模型连接测试失败: {str(e)}") logger.error(f"OpenAI 兼容 文案生成模型连接测试失败: {str(e)}")
@ -971,6 +1027,25 @@ def render_text_llm_settings(tr):
else: else:
text_validation_errors.append(error_msg) 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 密钥 # 验证 API 密钥
if st_text_api_key: if st_text_api_key:
is_valid, error_msg = validate_api_key(st_text_api_key, "文案生成") is_valid, error_msg = validate_api_key(st_text_api_key, "文案生成")
@ -1006,7 +1081,7 @@ def render_text_llm_settings(tr):
config.save_config() config.save_config()
# 清除缓存,确保下次使用新配置 # 清除缓存,确保下次使用新配置
UnifiedLLMService.clear_cache() 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")) st.success(tr("Text model config saved"))
except Exception as e: except Exception as e:
st.error(f"{tr('Failed to save config')}: {str(e)}") st.error(f"{tr('Failed to save config')}: {str(e)}")

View File

@ -26,6 +26,7 @@ from webui.tools.generate_short_summary import (
SCRIPT_TABLE_BASE_COLUMNS = ["_id", "video_id", "video_name", "timestamp", "picture", "narration", "OST"] SCRIPT_TABLE_BASE_COLUMNS = ["_id", "video_id", "video_name", "timestamp", "picture", "narration", "OST"]
SCRIPT_TABLE_TEXT_COLUMNS = {"video_name", "timestamp", "picture", "narration", "value"}
MODE_FILE = "file_selection" MODE_FILE = "file_selection"
MODE_AUTO = "auto" MODE_AUTO = "auto"
MODE_SHORT = "short" MODE_SHORT = "short"
@ -96,6 +97,9 @@ FILM_TV_TYPE_VALUES = {
"horror_thriller": "恐怖/惊悚", "horror_thriller": "恐怖/惊悚",
} }
SHORT_DRAMA_ORIGINAL_SOUND_RATIO_OPTIONS = list(range(0, 100, 10)) 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 = { SUMMARY_MODE_CONFIGS = {
MODE_FILM_SUMMARY: { MODE_FILM_SUMMARY: {
"mode_label_key": "Film TV Narration", "mode_label_key": "Film TV Narration",
@ -205,6 +209,14 @@ def _format_file_list_for_display(paths, max_items=3):
return f"{visible_names} +{len(file_names) - max_items}" return f"{visible_names} +{len(file_names) - max_items}"
def _safe_filename_fragment(value, fallback="translated"):
fragment = "".join(
char if char.isalnum() or char in {"-", "_"} else "_"
for char in str(value or "").strip()
).strip("_")
return fragment or fallback
def _read_subtitle_file(path): def _read_subtitle_file(path):
try: try:
return read_subtitle_text(path).text return read_subtitle_text(path).text
@ -658,8 +670,34 @@ def render_short_generate_options(tr):
渲染Short Generate模式下的特殊选项 渲染Short Generate模式下的特殊选项
在Short Generate模式下替换原有的输入框为自定义片段选项 在Short Generate模式下替换原有的输入框为自定义片段选项
""" """
summary_narration_panel(tr, SUMMARY_MODE_CONFIGS[MODE_SHORT_SUMMARY]) summary_config = SUMMARY_MODE_CONFIGS[MODE_SHORT_SUMMARY]
# 显示自定义片段数量选择器 summary_narration_panel(tr, summary_config)
type_option_key = _summary_state_key(summary_config, "type_option")
custom_type_key = _summary_state_key(summary_config, "custom_type")
type_options = [code for code, _ in summary_config["type_options"]]
if st.session_state.get(type_option_key) not in type_options:
st.session_state[type_option_key] = summary_config["default_type"]
show_custom_type = st.session_state.get(type_option_key, summary_config["default_type"]) == "custom"
option_cols = st.columns([1.1, 1.1, 1], vertical_alignment="bottom") if show_custom_type else st.columns([1.1, 1], vertical_alignment="bottom")
with option_cols[0]:
st.selectbox(
tr(summary_config["type_label_key"]),
options=type_options,
format_func=lambda code: tr(dict(summary_config["type_options"]).get(code, code)),
key=type_option_key,
)
option_index = 1
if show_custom_type:
with option_cols[option_index]:
st.text_input(
tr(summary_config["custom_type_label_key"]),
key=custom_type_key,
placeholder=tr(summary_config["custom_type_placeholder_key"]),
)
option_index += 1
with option_cols[option_index]:
custom_clips = st.number_input( custom_clips = st.number_input(
tr("自定义片段"), tr("自定义片段"),
min_value=1, min_value=1,
@ -721,6 +759,7 @@ def summary_narration_panel(tr, summary_config):
plot_analysis_key = _summary_state_key(summary_config, "plot_analysis") plot_analysis_key = _summary_state_key(summary_config, "plot_analysis")
plot_source_key = _summary_state_key(summary_config, "plot_analysis_subtitle_path") plot_source_key = _summary_state_key(summary_config, "plot_analysis_subtitle_path")
plot_signature_key = _summary_state_key(summary_config, "plot_analysis_signature") plot_signature_key = _summary_state_key(summary_config, "plot_analysis_signature")
pending_plot_key = _summary_state_key(summary_config, "pending_plot_analysis")
st.markdown( st.markdown(
f""" f"""
@ -807,6 +846,15 @@ def summary_narration_panel(tr, summary_config):
st.session_state[plot_analysis_key] = "" st.session_state[plot_analysis_key] = ""
st.session_state[plot_source_key] = "" st.session_state[plot_source_key] = ""
st.session_state[plot_signature_key] = "" st.session_state[plot_signature_key] = ""
st.session_state.pop(pending_plot_key, None)
else:
pending_plot = st.session_state.pop(pending_plot_key, None)
if isinstance(pending_plot, dict) and pending_plot.get("signature") == current_signature:
pending_analysis = str(pending_plot.get("plot_analysis") or "")
if pending_analysis:
st.session_state[plot_analysis_key] = pending_analysis
st.session_state[plot_source_key] = pending_plot.get("subtitle_path") or current_subtitle_path
st.session_state[plot_signature_key] = current_signature
if analyze_plot_clicked: if analyze_plot_clicked:
with st.spinner(tr("Analyzing plot...")): with st.spinner(tr("Analyzing plot...")):
@ -995,10 +1043,17 @@ def _script_json_to_table(script_data):
{"value": json.dumps(item, ensure_ascii=False)} {"value": json.dumps(item, ensure_ascii=False)}
for item in script_data for item in script_data
] ]
return pd.DataFrame(rows, columns=["value"]) return _normalize_script_table_types(pd.DataFrame(rows, columns=["value"]))
columns = _ordered_script_columns(script_data) columns = _ordered_script_columns(script_data)
return pd.DataFrame(script_data, columns=columns) return _normalize_script_table_types(pd.DataFrame(script_data, columns=columns))
def _normalize_script_table_types(table_data):
for column in SCRIPT_TABLE_TEXT_COLUMNS:
if column in table_data.columns:
table_data[column] = table_data[column].where(table_data[column].notna(), "").astype(str).astype("object")
return table_data
def _normalize_script_table_value(column, value): def _normalize_script_table_value(column, value):
@ -1144,6 +1199,8 @@ def render_fun_asr_transcription(tr):
) )
backend = backend_options[backend_label] backend = backend_options[backend_label]
st.markdown(tr("Subtitle transcription package downloads"))
if backend == "upload": if backend == "upload":
render_subtitle_upload(tr) render_subtitle_upload(tr)
elif backend == "local": elif backend == "local":
@ -1211,24 +1268,42 @@ def render_fun_asr_transcription(tr):
# 上传字幕面板会在本轮渲染中更新 session_state这里重新读取一次保证按钮状态同步。 # 上传字幕面板会在本轮渲染中更新 session_state这里重新读取一次保证按钮状态同步。
subtitle_paths = _selected_subtitle_paths() subtitle_paths = _selected_subtitle_paths()
can_transcribe = backend != "upload" and bool(media_paths) can_transcribe = backend != "upload" and bool(media_paths)
can_correct_subtitles = bool(subtitle_paths) can_manage_subtitles = bool(subtitle_paths)
saved_target_language = str(config.ui.get("subtitle_translate_target_language", "中文") or "中文")
with subtitle_cols[1]: with subtitle_cols[1]:
action_cols = st.columns(2)
with action_cols[0]:
transcribe_clicked = st.button( transcribe_clicked = st.button(
tr("Transcribe subtitles"), tr("Transcribe subtitles"),
key="fun_asr_transcribe", key="fun_asr_transcribe",
disabled=not can_transcribe, disabled=not can_transcribe,
use_container_width=True, use_container_width=True,
) )
with action_cols[1]:
subtitle_action_cols = st.columns(3, vertical_alignment="bottom")
with subtitle_action_cols[0]:
target_language = st.text_input(
tr("Subtitle target language"),
value=saved_target_language,
key="subtitle_translate_target_language",
placeholder=tr("Subtitle target language placeholder"),
)
with subtitle_action_cols[1]:
translate_clicked = st.button(
tr("Translate subtitles"),
key="subtitle_translate",
disabled=not can_manage_subtitles,
use_container_width=True,
)
with subtitle_action_cols[2]:
correct_clicked = st.button( correct_clicked = st.button(
tr("Calibrate subtitles"), tr("Calibrate subtitles"),
key="subtitle_correct", key="subtitle_correct",
disabled=not can_correct_subtitles, disabled=not can_manage_subtitles,
use_container_width=True, use_container_width=True,
) )
target_language = str(target_language or "").strip() or "中文"
if correct_clicked: if correct_clicked:
from app.services import subtitle_corrector from app.services import subtitle_corrector
@ -1278,6 +1353,87 @@ def render_fun_asr_transcription(tr):
st.error(f"{tr('Subtitle calibration failed')}: {str(e)}") st.error(f"{tr('Subtitle calibration failed')}: {str(e)}")
return return
if translate_clicked:
from app.services import subtitle_translator
text_provider = config.app.get('text_llm_provider', 'openai').lower()
text_api_key = config.app.get(f'text_{text_provider}_api_key')
text_base_url = config.app.get(f'text_{text_provider}_base_url')
translated_paths = []
try:
config.ui["subtitle_translate_target_language"] = target_language
config.save_config()
spinner_text = tr("Translating subtitles...").format(language=target_language)
with st.spinner(spinner_text):
progress_bar = st.progress(0)
progress_caption = st.empty()
target_suffix = _safe_filename_fragment(target_language)
for index, subtitle_path in enumerate(subtitle_paths, start=1):
subtitle_name = (
f"{os.path.splitext(os.path.basename(subtitle_path))[0]}"
f"_translated_{target_suffix}.srt"
)
output_path = _unique_file_path(utils.subtitle_dir(), subtitle_name)
subtitle_file_label = os.path.basename(subtitle_path)
def update_translation_progress(
completed,
total,
message,
file_index=index,
file_label=subtitle_file_label,
):
total = max(int(total or 0), 1)
completed = max(0, min(int(completed or 0), total))
file_progress = completed / total
overall_progress = ((file_index - 1) + file_progress) / max(len(subtitle_paths), 1)
progress_bar.progress(min(overall_progress, 1.0))
progress_caption.caption(
tr("Subtitle translation progress").format(
file=file_label,
completed=completed,
total=total,
message=message,
)
)
translated_path = subtitle_translator.translate_subtitle_file(
subtitle_file=subtitle_path,
output_file=output_path,
target_language=target_language,
provider=text_provider,
api_key=text_api_key,
base_url=text_base_url,
progress_callback=update_translation_progress,
)
translated_paths.append(translated_path)
progress_bar.progress(index / len(subtitle_paths))
progress_caption.empty()
progress_bar.empty()
_set_subtitle_state(translated_paths)
success_placeholder = st.empty()
if len(translated_paths) == 1:
success_placeholder.success(
tr("Subtitle translation succeeded").format(file=os.path.basename(translated_paths[0]))
)
else:
success_placeholder.success(
tr("Subtitle translation succeeded for multiple files").format(
count=len(translated_paths),
files=_format_file_list_for_display(translated_paths),
)
)
time.sleep(3)
success_placeholder.empty()
except Exception as e:
logger.error(f"字幕翻译失败: {traceback.format_exc()}")
st.error(f"{tr('Subtitle translation failed')}: {str(e)}")
return
if not transcribe_clicked: if not transcribe_clicked:
return return
@ -1421,6 +1577,7 @@ def render_script_buttons(tr, params):
type_option_key = _summary_state_key(summary_config, "type_option") type_option_key = _summary_state_key(summary_config, "type_option")
custom_type_key = _summary_state_key(summary_config, "custom_type") custom_type_key = _summary_state_key(summary_config, "custom_type")
original_sound_ratio_key = _summary_state_key(summary_config, "original_sound_ratio") 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") language_option_key = _summary_state_key(summary_config, "narration_language_option")
custom_language_key = _summary_state_key(summary_config, "custom_narration_language") custom_language_key = _summary_state_key(summary_config, "custom_narration_language")
narration_copy_key = _summary_state_key(summary_config, "narration_copy") narration_copy_key = _summary_state_key(summary_config, "narration_copy")
@ -1439,7 +1596,7 @@ def render_script_buttons(tr, params):
config_col_widths = [1.15] config_col_widths = [1.15]
if show_custom_type: if show_custom_type:
config_col_widths.append(1.15) 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: if show_custom_language:
config_col_widths.append(1.15) config_col_widths.append(1.15)
@ -1470,6 +1627,16 @@ def render_script_buttons(tr, params):
key=original_sound_ratio_key, key=original_sound_ratio_key,
) )
config_col_index += 1 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]: with config_cols[config_col_index]:
st.selectbox( st.selectbox(
tr("解说语言"), tr("解说语言"),
@ -1510,6 +1677,7 @@ def render_script_buttons(tr, params):
type_option_key = _summary_state_key(summary_config, "type_option") type_option_key = _summary_state_key(summary_config, "type_option")
custom_type_key = _summary_state_key(summary_config, "custom_type") custom_type_key = _summary_state_key(summary_config, "custom_type")
original_sound_ratio_key = _summary_state_key(summary_config, "original_sound_ratio") 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") language_option_key = _summary_state_key(summary_config, "narration_language_option")
custom_language_key = _summary_state_key(summary_config, "custom_narration_language") custom_language_key = _summary_state_key(summary_config, "custom_narration_language")
narration_copy_key = _summary_state_key(summary_config, "narration_copy") narration_copy_key = _summary_state_key(summary_config, "narration_copy")
@ -1521,6 +1689,9 @@ def render_script_buttons(tr, params):
narration_language = _resolve_summary_narration_language(summary_config) narration_language = _resolve_summary_narration_language(summary_config)
drama_genre = _resolve_summary_type(summary_config) drama_genre = _resolve_summary_type(summary_config)
original_sound_ratio = int(st.session_state.get(original_sound_ratio_key, 30)) 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 ( if (
st.session_state.get(type_option_key) == "custom" st.session_state.get(type_option_key) == "custom"
and not str(st.session_state.get(custom_type_key, '') or '').strip() and not str(st.session_state.get(custom_type_key, '') or '').strip()
@ -1567,6 +1738,7 @@ def render_script_buttons(tr, params):
video_paths=_selected_video_paths(), video_paths=_selected_video_paths(),
narration_language=narration_language, narration_language=narration_language,
drama_genre=drama_genre, drama_genre=drama_genre,
narration_word_count=narration_word_count,
prompt_category=summary_config["prompt_category"], prompt_category=summary_config["prompt_category"],
search_keywords=summary_config["search_keywords"], search_keywords=summary_config["search_keywords"],
empty_title_message_key=summary_config["empty_title_message_key"], empty_title_message_key=summary_config["empty_title_message_key"],
@ -1616,8 +1788,66 @@ def render_script_buttons(tr, params):
generate_script_docu(params, tr) generate_script_docu(params, tr)
elif script_path == "short": elif script_path == "short":
# 执行 短剧混剪 脚本生成 # 执行 短剧混剪 脚本生成
summary_config = SUMMARY_MODE_CONFIGS[MODE_SHORT_SUMMARY]
type_option_key = _summary_state_key(summary_config, "type_option")
custom_type_key = _summary_state_key(summary_config, "custom_type")
web_search_key = _summary_state_key(summary_config, "web_search_enabled")
plot_analysis_key = _summary_state_key(summary_config, "plot_analysis")
plot_source_key = _summary_state_key(summary_config, "plot_analysis_subtitle_path")
plot_signature_key = _summary_state_key(summary_config, "plot_analysis_signature")
pending_plot_key = _summary_state_key(summary_config, "pending_plot_analysis")
if (
st.session_state.get(type_option_key) == "custom"
and not str(st.session_state.get(custom_type_key, '') or '').strip()
):
st.error(tr(summary_config["custom_type_empty_key"]))
st.stop()
subtitle_paths = _selected_subtitle_paths()
subtitle_path = subtitle_paths[0] if subtitle_paths else None
video_theme = st.session_state.get('video_theme')
web_search_enabled = bool(st.session_state.get(web_search_key, False))
current_signature = _short_drama_plot_analysis_signature(
subtitle_paths,
video_theme,
web_search_enabled,
_selected_video_paths(),
)
plot_analysis = ""
if st.session_state.get(plot_signature_key) == current_signature:
plot_analysis = st.session_state.get(plot_analysis_key, '')
elif (
not web_search_enabled
and st.session_state.get(plot_source_key) == subtitle_path
):
plot_analysis = st.session_state.get(plot_analysis_key, '')
custom_clips = st.session_state.get('custom_clips') custom_clips = st.session_state.get('custom_clips')
generate_script_short(tr, params, custom_clips) short_result = generate_script_short(
tr,
params,
custom_clips,
subtitle_paths=subtitle_paths,
video_theme=video_theme,
temperature=st.session_state.get('temperature', 0.7),
plot_analysis=plot_analysis,
subtitle_content=st.session_state.get('subtitle_content', ''),
enable_web_search=web_search_enabled,
video_paths=_selected_video_paths(),
drama_genre=_resolve_short_drama_type(),
prompt_category=summary_config["prompt_category"],
search_keywords=summary_config["search_keywords"],
empty_title_message_key=summary_config["empty_title_message_key"],
web_search_context_description=summary_config["web_search_context_description"],
)
if short_result and short_result.get("plot_analysis"):
st.session_state[pending_plot_key] = {
"plot_analysis": short_result["plot_analysis"],
"subtitle_path": subtitle_path,
"signature": current_signature,
}
st.session_state[plot_source_key] = subtitle_path
st.session_state[plot_signature_key] = current_signature
else: else:
load_script(tr, script_path) load_script(tr, script_path)

View File

@ -47,6 +47,13 @@ SUBTITLE_PREVIEW_FALLBACK_SIZES = {
"landscape": (1920, 1080), "landscape": (1920, 1080),
} }
SUBTITLE_PREVIEW_ORIENTATIONS = ("portrait", "landscape")
SUBTITLE_PREVIEW_ORIENTATION_LABELS = {
"portrait": "Portrait Safe Area",
"landscape": "Landscape Safe Area",
}
def render_subtitle_panel(tr): def render_subtitle_panel(tr):
"""渲染字幕设置面板""" """渲染字幕设置面板"""
@ -130,6 +137,25 @@ def _get_current_preview_video_path():
return "" return ""
def _normalize_subtitle_preview_orientation(value, tr=None):
if value in SUBTITLE_PREVIEW_ORIENTATIONS:
return value
labels = {
label: orientation
for orientation, label in SUBTITLE_PREVIEW_ORIENTATION_LABELS.items()
}
if tr:
labels.update(
{
tr(label): orientation
for orientation, label in SUBTITLE_PREVIEW_ORIENTATION_LABELS.items()
}
)
return labels.get(str(value), "portrait")
def _save_subtitle_mask_preview_video(uploaded_file): def _save_subtitle_mask_preview_video(uploaded_file):
if uploaded_file is None: if uploaded_file is None:
return "" return ""
@ -753,14 +779,19 @@ def _render_subtitle_preview_image(tr):
stroke_color = st.session_state.get("stroke_color", config.ui.get("stroke_color", "#000000")) stroke_color = st.session_state.get("stroke_color", config.ui.get("stroke_color", "#000000"))
stroke_width = float(st.session_state.get("stroke_width", config.ui.get("stroke_width", 1.5)) or 0) stroke_width = float(st.session_state.get("stroke_width", config.ui.get("stroke_width", 1.5)) or 0)
orientation_default = _normalize_subtitle_preview_orientation(
st.session_state.get("subtitle_preview_orientation", "portrait"),
tr,
)
st.session_state["subtitle_preview_orientation"] = orientation_default
orientation = st.pills( orientation = st.pills(
tr("Subtitle Preview Orientation"), tr("Subtitle Preview Orientation"),
options=["portrait", "landscape"], options=SUBTITLE_PREVIEW_ORIENTATIONS,
default=st.session_state.get("subtitle_preview_orientation", "portrait"), format_func=lambda value: tr(SUBTITLE_PREVIEW_ORIENTATION_LABELS[value]),
format_func=lambda value: tr("Portrait Safe Area") if value == "portrait" else tr("Landscape Safe Area"),
key="subtitle_preview_orientation", key="subtitle_preview_orientation",
width="stretch", width="stretch",
) or "portrait" ) or orientation_default
_render_subtitle_position_controls(tr, orientation) _render_subtitle_position_controls(tr, orientation)
preview_text = tr("Subtitle Preview Sample Text") preview_text = tr("Subtitle Preview Sample Text")

View File

@ -4,8 +4,54 @@ import shutil
from loguru import logger from loguru import logger
from app.config import config from app.config import config
from app.services.llm.unified_service import UnifiedLLMService
from app.utils import ffmpeg_detector, ffmpeg_utils from app.utils import ffmpeg_detector, ffmpeg_utils
from app.utils.utils import storage_dir from app.utils.utils import clear_keyframes_cache, storage_dir
APPLICATION_CACHE_SESSION_KEYS = (
"fonts_cache",
"video_files_cache",
"songs_cache",
"ffmpeg_engine_report",
)
def _clear_streamlit_caches():
st.cache_data.clear()
cache_resource = getattr(st, "cache_resource", None)
if cache_resource is not None:
cache_resource.clear()
def clear_application_cache(
session_state=None,
clear_streamlit_cache=None,
clear_llm_cache=None,
clear_keyframes_cache=None,
reset_ffmpeg_detection=None,
):
"""Clear runtime caches that can keep stale UI/model/media state."""
state = st.session_state if session_state is None else session_state
for key in APPLICATION_CACHE_SESSION_KEYS:
state.pop(key, None)
operations = (
("streamlit", clear_streamlit_cache or _clear_streamlit_caches),
("llm", clear_llm_cache or UnifiedLLMService.clear_cache),
("keyframes", clear_keyframes_cache or globals()["clear_keyframes_cache"]),
("ffmpeg", reset_ffmpeg_detection or ffmpeg_utils.reset_hwaccel_detection),
)
errors = []
for name, operation in operations:
try:
operation()
except Exception as exc:
errors.append(f"{name}: {exc}")
logger.error(f"Failed to clear {name} cache: {exc}")
return errors
def clear_directory(dir_path, tr): def clear_directory(dir_path, tr):
@ -188,7 +234,7 @@ def render_ffmpeg_engine_settings(tr):
def render_system_panel(tr): def render_system_panel(tr):
"""渲染系统设置面板""" """渲染系统设置面板"""
with st.expander(tr("System settings"), expanded=False): with st.expander(tr("System settings"), expanded=False):
col1, col2, col3 = st.columns(3) col1, col2, col3, col4 = st.columns(4)
with col1: with col1:
if st.button(tr("Clear frames"), use_container_width=True): if st.button(tr("Clear frames"), use_container_width=True):
@ -202,4 +248,12 @@ def render_system_panel(tr):
if st.button(tr("Clear tasks"), use_container_width=True): if st.button(tr("Clear tasks"), use_container_width=True):
clear_directory(os.path.join(storage_dir(), "tasks"), tr) clear_directory(os.path.join(storage_dir(), "tasks"), tr)
with col4:
if st.button(tr("Clear Cache"), use_container_width=True):
errors = clear_application_cache()
if errors:
st.error(f"{tr('Failed to clear cache')}: {'; '.join(errors)}")
else:
st.success(tr("Cache cleared"))
render_ffmpeg_engine_settings(tr) render_ffmpeg_engine_settings(tr)

View File

@ -0,0 +1,63 @@
import unittest
from webui.components.audio_settings import (
_normalize_source_pills_value,
get_tts_engine_options,
)
def zh_tr(key):
return {
"Select from Resource Directory": "从资源目录选择",
"Upload Reference Audio": "上传参考音频",
"Upload Background Music": "上传背景音乐",
}.get(key, key)
class AudioSettingsSourcePillsTests(unittest.TestCase):
def test_tts_engine_options_include_indextts_15_macos_as_local_engine(self):
options = get_tts_engine_options(lambda key: {
"Local Deployment": "本地部署",
"Cloud Service": "云服务",
}.get(key, key))
self.assertEqual(
"IndexTTS-1.5-macOS [本地部署]",
options["indextts_macos"],
)
def test_normalize_source_pills_value_keeps_canonical_value(self):
options = {
"resource": "Select from Resource Directory",
"upload": "Upload Reference Audio",
}
self.assertEqual("upload", _normalize_source_pills_value("upload", options, "resource", zh_tr))
def test_normalize_source_pills_value_migrates_current_translated_label(self):
options = {
"resource": "Select from Resource Directory",
"upload": "Upload Reference Audio",
}
self.assertEqual("resource", _normalize_source_pills_value("从资源目录选择", options, "upload", zh_tr))
def test_normalize_source_pills_value_migrates_untranslated_label_key(self):
options = {
"resource": "Select from Resource Directory",
"upload": "Upload Background Music",
}
self.assertEqual("upload", _normalize_source_pills_value("Upload Background Music", options, "resource", zh_tr))
def test_normalize_source_pills_value_falls_back_to_default(self):
options = {
"resource": "Select from Resource Directory",
"upload": "Upload Reference Audio",
}
self.assertEqual("resource", _normalize_source_pills_value("invalid", options, "resource", zh_tr))
if __name__ == "__main__":
unittest.main()

View File

@ -0,0 +1,21 @@
import unittest
from webui.components.subtitle_settings import _normalize_subtitle_preview_orientation
class SubtitleSettingsPreviewOrientationTests(unittest.TestCase):
def test_normalize_subtitle_preview_orientation_accepts_canonical_values(self):
self.assertEqual("portrait", _normalize_subtitle_preview_orientation("portrait"))
self.assertEqual("landscape", _normalize_subtitle_preview_orientation("landscape"))
def test_normalize_subtitle_preview_orientation_migrates_legacy_labels(self):
self.assertEqual("portrait", _normalize_subtitle_preview_orientation("Portrait Safe Area"))
self.assertEqual("landscape", _normalize_subtitle_preview_orientation("Landscape Safe Area"))
def test_normalize_subtitle_preview_orientation_falls_back_to_portrait(self):
self.assertEqual("portrait", _normalize_subtitle_preview_orientation(None))
self.assertEqual("portrait", _normalize_subtitle_preview_orientation("unexpected"))
if __name__ == "__main__":
unittest.main()

View File

@ -0,0 +1,46 @@
import unittest
from webui.components.system_settings import clear_application_cache
class SystemSettingsCacheTests(unittest.TestCase):
def test_clear_application_cache_clears_runtime_caches(self):
session_state = {
"fonts_cache": ["SimHei"],
"video_files_cache": ["a.mp4"],
"songs_cache": ["bgm.mp3"],
"ffmpeg_engine_report": {"ffmpeg_available": True},
"unrelated": "keep",
}
calls = []
errors = clear_application_cache(
session_state=session_state,
clear_streamlit_cache=lambda: calls.append("streamlit"),
clear_llm_cache=lambda: calls.append("llm"),
clear_keyframes_cache=lambda: calls.append("keyframes"),
reset_ffmpeg_detection=lambda: calls.append("ffmpeg"),
)
self.assertEqual([], errors)
self.assertEqual(["streamlit", "llm", "keyframes", "ffmpeg"], calls)
self.assertEqual({"unrelated": "keep"}, session_state)
def test_clear_application_cache_reports_clear_failures(self):
def fail():
raise RuntimeError("boom")
errors = clear_application_cache(
session_state={},
clear_streamlit_cache=fail,
clear_llm_cache=lambda: None,
clear_keyframes_cache=lambda: None,
reset_ffmpeg_detection=lambda: None,
)
self.assertEqual(1, len(errors))
self.assertIn("boom", errors[0])
if __name__ == "__main__":
unittest.main()

View File

@ -54,7 +54,21 @@
"Custom Background Music": "Custom Background Music", "Custom Background Music": "Custom Background Music",
"Custom Background Music File": "Please enter the file path of the custom background music", "Custom Background Music File": "Please enter the file path of the custom background music",
"Background Music Source": "Background Music Source", "Background Music Source": "Background Music Source",
"Background Music Source Help": "Choose background music from the resource directory, upload a new file, or disable background music.", "Background Music Source Help": "Choose background music from the resource directory, upload a new file, let Sonilo generate music from the visuals, or disable background music.",
"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 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", "Upload Background Music": "Upload Background Music",
"Background Music Path Help": "Choose the background music used for video synthesis.", "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.", "No Background Music Resources Found": "No background music resources found. Please upload a background music file.",
@ -197,6 +211,10 @@
"Text API Key": "Text API Key", "Text API Key": "Text API Key",
"Text Base URL": "Text Base URL", "Text Base URL": "Text Base URL",
"Text Model Name": "Text Model Name", "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 P": "Top P",
"Top K": "Top K", "Top K": "Top K",
"Max Output Tokens": "Max Output Tokens", "Max Output Tokens": "Max Output Tokens",
@ -333,6 +351,7 @@
"影视类型": "Film/TV Type", "影视类型": "Film/TV Type",
"自定义影视类型": "Custom Film/TV Type", "自定义影视类型": "Custom Film/TV Type",
"原片占比": "Original Footage Ratio", "原片占比": "Original Footage Ratio",
"文案字数": "Copy Length",
"例如:豪门虐恋": "For example: billionaire angst romance", "例如:豪门虐恋": "For example: billionaire angst romance",
"例如:悬疑犯罪": "For example: suspense crime", "例如:悬疑犯罪": "For example: suspense crime",
"请输入自定义短剧类型": "Please enter a custom short drama type", "请输入自定义短剧类型": "Please enter a custom short drama type",
@ -373,13 +392,19 @@
"Tencent Cloud TTS use case": "Personal and enterprise users who need stable Chinese speech synthesis", "Tencent Cloud TTS use case": "Personal and enterprise users who need stable Chinese speech synthesis",
"Tongyi Qwen3 TTS features": "Alibaba Cloud Tongyi Qwen speech synthesis with high-quality voices and multiple voice options.", "Tongyi Qwen3 TTS features": "Alibaba Cloud Tongyi Qwen speech synthesis with high-quality voices and multiple voice options.",
"High-quality Chinese speech synthesis use case": "Users who need high-quality Chinese speech synthesis", "High-quality Chinese speech synthesis use case": "Users who need high-quality Chinese speech synthesis",
"IndexTTS features": "A locally or privately deployed IndexTTS-1.5 voice-cloning engine. Choose a resource audio file or upload a reference audio file, then synthesize narration in that voice.", "IndexTTS features": "A locally or privately deployed IndexTTS-1.5 voice-cloning engine for Windows. Choose or upload reference audio to synthesize narration in that voice.",
"IndexTTS use case": "Best for fixed narrator voices, character dubbing, or generating multiple videos with the same voice. Start the IndexTTS-1.5 API service before use. Deployment package: https://pan.quark.cn/s/0767c9bcefd5", "IndexTTS use case": "Best for fixed narrator voices, character dubbing, and batch generation on Windows. Start the IndexTTS-1.5 API service before use. [Download the deployment package](https://cutagent.online/resources/indextts15-windows).",
"IndexTTS download link": "Download link: https://pan.quark.cn/s/0767c9bcefd5", "IndexTTS download link": "Download packages:\n\n- **macOS**: [Download](https://cutagent.online/resources/indextts15-mlx-macos)\n- **Windows**: [Download](https://cutagent.online/resources/indextts15-windows)",
"IndexTTS2 features": "A locally or privately deployed IndexTTS-2 voice-cloning engine with emotion control and fuller generation parameters.", "IndexTTS macOS features": "A local IndexTTS-1.5 MLX voice-cloning engine for Apple Silicon that uploads reference audio to synthesize narration.",
"IndexTTS2 use case": "Best for fixed voices, emotional narration, and local speech synthesis workflows that need finer sampling controls. Start the IndexTTS-2 API service before use.", "IndexTTS macOS use case": "Best for local fixed narrator voices and character dubbing on Apple Silicon Macs. Start IndexTTS-MLX-1.5-Pack before use. [Download the deployment package](https://cutagent.online/resources/indextts15-mlx-macos).",
"IndexTTS2 features": "A locally deployed IndexTTS-2 MLX Pack voice-cloning engine with emotion control and advanced generation parameters.",
"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 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.", "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.", "Doubao TTS features": "Volcengine Doubao speech synthesis with multiple voices and emotions, plus fast access in mainland China.",
"Local Deployment": "Local Deployment", "Local Deployment": "Local Deployment",
"Cloud Service": "Cloud Service", "Cloud Service": "Cloud Service",
@ -472,6 +497,7 @@
"Characters": "characters", "Characters": "characters",
"Ali Bailian Fun-ASR Subtitle Transcription": "Subtitle Processing", "Ali Bailian Fun-ASR Subtitle Transcription": "Subtitle Processing",
"Subtitle Processing Method": "Subtitle Processing Method", "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", "Fun-ASR Backend": "Fun-ASR Backend",
"Local FunASR-Pack API": "FunASR (Local)", "Local FunASR-Pack API": "FunASR (Local)",
"Local FireRedASR API": "FireRedASR2 (Local)", "Local FireRedASR API": "FireRedASR2 (Local)",
@ -483,7 +509,7 @@
"Auto Transcription FireRed Caption": "After the final video is merged, it will be converted to SRT subtitles through the locally running FireRedASR2-AED-Pack API.", "Auto Transcription FireRed Caption": "After the final video is merged, it will be converted to SRT subtitles through the locally running FireRedASR2-AED-Pack API.",
"Auto Transcription Online Caption": "After the final video is merged, it will be uploaded to temporary Ali Bailian storage and converted to SRT subtitles with fun-asr.", "Auto Transcription Online Caption": "After the final video is merged, it will be uploaded to temporary Ali Bailian storage and converted to SRT subtitles with fun-asr.",
"Local FunASR-Pack API URL": "Local FunASR-Pack API URL", "Local FunASR-Pack API URL": "Local FunASR-Pack API URL",
"Local FunASR-Pack API URL Help": "For example, http://127.0.0.1:7860. A full /asr endpoint URL is also supported.", "Local FunASR-Pack API URL Help": "For example, http://127.0.0.1:7860. A full /asr, /v1, or /v1/audio/transcriptions URL is also supported.",
"Local FireRedASR API URL": "Local ASR API URL", "Local FireRedASR API URL": "Local ASR API URL",
"Local FireRedASR API URL Help": "For example, http://127.0.0.1:7867. A full /asr endpoint URL is also supported.", "Local FireRedASR API URL Help": "For example, http://127.0.0.1:7867. A full /asr endpoint URL is also supported.",
"Fun-ASR Hotword": "Hotword", "Fun-ASR Hotword": "Hotword",
@ -501,6 +527,9 @@
"Selected video files do not exist": "These selected video files do not exist. Please select or upload them again: {files}", "Selected video files do not exist": "These selected video files do not exist. Please select or upload them again: {files}",
"Transcribe subtitles": "Transcribe Subtitles", "Transcribe subtitles": "Transcribe Subtitles",
"Calibrate subtitles": "Calibrate Subtitles", "Calibrate subtitles": "Calibrate Subtitles",
"Translate subtitles": "Translate Subtitles",
"Subtitle target language": "Target language",
"Subtitle target language placeholder": "Chinese",
"Please enter Ali Bailian API Key": "Please enter the Ali Bailian API Key first", "Please enter Ali Bailian API Key": "Please enter the Ali Bailian API Key first",
"Please enter local FunASR-Pack API URL": "Please enter the local FunASR-Pack API URL first", "Please enter local FunASR-Pack API URL": "Please enter the local FunASR-Pack API URL first",
"Please enter local FireRedASR API URL": "Please enter the local ASR API URL first", "Please enter local FireRedASR API URL": "Please enter the local ASR API URL first",
@ -515,6 +544,11 @@
"Subtitle calibration succeeded": "Subtitle calibration succeeded: {file}", "Subtitle calibration succeeded": "Subtitle calibration succeeded: {file}",
"Subtitle calibration succeeded for multiple files": "Subtitle calibration succeeded for {count} files: {files}", "Subtitle calibration succeeded for multiple files": "Subtitle calibration succeeded for {count} files: {files}",
"Subtitle calibration failed": "Subtitle calibration failed", "Subtitle calibration failed": "Subtitle calibration failed",
"Translating subtitles...": "Translating subtitles to {language} with the LLM, please wait...",
"Subtitle translation succeeded": "Subtitle translation succeeded: {file}",
"Subtitle translation succeeded for multiple files": "Subtitle translation succeeded for {count} files: {files}",
"Subtitle translation failed": "Subtitle translation failed",
"Subtitle translation progress": "{file}: {completed}/{total} items · {message}",
"Transcribed subtitles storage hint": "Previously transcribed subtitles are saved in {path}; drag a file from that folder to upload", "Transcribed subtitles storage hint": "Previously transcribed subtitles are saved in {path}; drag a file from that folder to upload",
"Tavily Search Settings": "Tavily Web Search", "Tavily Search Settings": "Tavily Web Search",
"Tavily API Key": "Tavily API Key", "Tavily API Key": "Tavily API Key",
@ -556,8 +590,30 @@
"Select Qwen3 TTS Voice": "Select a Qwen3 TTS voice", "Select Qwen3 TTS Voice": "Select a Qwen3 TTS voice",
"API URL": "API URL", "API URL": "API URL",
"IndexTTS API URL Help": "IndexTTS-1.5 API service URL", "IndexTTS API URL Help": "IndexTTS-1.5 API service URL",
"IndexTTS2 API URL Help": "IndexTTS-2 API service URL. You can enter the service root or the full /tts endpoint.", "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.", "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": "Synthesis Language",
"OmniVoice Language Code Help": "The language parameter sent to OmniVoice-Pack, such as zh or en.", "OmniVoice Language Code Help": "The language parameter sent to OmniVoice-Pack, such as zh or en.",
"OmniVoice Generation Mode": "Generation Mode", "OmniVoice Generation Mode": "Generation Mode",
@ -610,7 +666,18 @@
"Enable Sampling Help": "Enable sampling for more natural speech.", "Enable Sampling Help": "Enable sampling for more natural speech.",
"IndexTTS Usage Instructions Title": "IndexTTS-1.5 Usage Instructions", "IndexTTS Usage Instructions Title": "IndexTTS-1.5 Usage Instructions",
"IndexTTS Usage Instructions": "**Zero-shot voice cloning**\n\n1. **Prepare reference audio**: upload or specify a clear audio file (3-10 seconds recommended)\n2. **Set API URL**: make sure the IndexTTS-1.5 service is running\n3. **Start synthesis**: the system will use the reference voice to synthesize new speech\n\n**Notes**:\n- Reference audio quality directly affects synthesis quality\n- Use clean audio without background noise when possible\n- Keep text length within a reasonable range\n- The first synthesis may take longer", "IndexTTS Usage Instructions": "**Zero-shot voice cloning**\n\n1. **Prepare reference audio**: upload or specify a clear audio file (3-10 seconds recommended)\n2. **Set API URL**: make sure the IndexTTS-1.5 service is running\n3. **Start synthesis**: the system will use the reference voice to synthesize new speech\n\n**Notes**:\n- Reference audio quality directly affects synthesis quality\n- Use clean audio without background noise when possible\n- Keep text length within a reasonable range\n- The first synthesis may take longer",
"IndexTTS macOS Usage Instructions Title": "IndexTTS-1.5-macOS Usage Instructions",
"IndexTTS macOS Usage Instructions": "**Local voice cloning on Apple Silicon**\n\n1. Double-click `start.command` in the Pack to start the service\n2. Keep the default API URL `http://127.0.0.1:7866`, or enter the full upload endpoint\n3. Select clean reference audio and start synthesis\n\nThe first request usually takes longer while the MLX model loads. This engine requires an Apple Silicon Mac.",
"IndexTTS2 Emotion Parameters": "Emotion Parameters", "IndexTTS2 Emotion Parameters": "Emotion Parameters",
"IndexTTS2 Emotion": "Emotion Override",
"IndexTTS2 Emotion Help": "Leave blank to preserve the reference audio emotion. Use a label such as happy or a weighted mix such as happy:0.7,calm:0.3.",
"IndexTTS2 Emotion Placeholder": "e.g. happy:0.7,calm:0.3",
"IndexTTS2 Emotion Audio Unsupported": "The IndexTTS-2 MLX Pack does not support a separate emotion reference audio. The speaker reference audio emotion will be used.",
"IndexTTS2 Speed": "Speech Speed",
"IndexTTS2 Speed Help": "Speech speed sent to the IndexTTS-2 MLX Pack (0.5x2.0x).",
"IndexTTS2 Seed": "Seed",
"IndexTTS2 Seed Help": "Optional integer seed for reproducible generation. Leave blank for random sampling.",
"IndexTTS2 Seed Placeholder": "Random",
"Emotion Mode": "Emotion Mode", "Emotion Mode": "Emotion Mode",
"Emotion Mode Help": "Choose the emotion control source for IndexTTS-2.", "Emotion Mode Help": "Choose the emotion control source for IndexTTS-2.",
"Emotion Mode Speaker": "Same as speaker reference", "Emotion Mode Speaker": "Same as speaker reference",
@ -634,16 +701,27 @@
"Emotion Melancholic": "Melancholic", "Emotion Melancholic": "Melancholic",
"Emotion Surprised": "Surprised", "Emotion Surprised": "Surprised",
"Emotion Calm": "Calm", "Emotion Calm": "Calm",
"Diffusion Steps": "Diffusion Steps",
"Diffusion Steps Help": "Number of diffusion steps. Higher values can improve quality but take longer.",
"CFG Rate": "CFG Rate",
"CFG Rate Help": "Classifier-free guidance strength for the diffusion stage.",
"Interval Silence": "Inter-segment Silence (ms)",
"Interval Silence Help": "Silence inserted between text segments by the Pack.",
"Segment Overlap": "Segment Overlap (ms)",
"Segment Overlap Help": "Crossfade overlap between generated text segments.",
"IndexTTS2 Top K Help": "Top-k sampling value for IndexTTS-2 MLX Pack. It must be at least 1.",
"Max Text Tokens Per Segment": "Max Text Tokens Per Segment", "Max Text Tokens Per Segment": "Max Text Tokens Per Segment",
"Max Text Tokens Per Segment Help": "Maximum text tokens per segment for IndexTTS-2 inference.", "Max Text Tokens Per Segment Help": "Maximum text tokens per segment for IndexTTS-2 inference.",
"Max Mel Tokens": "Max Mel Tokens", "Max Mel Tokens": "Max Mel Tokens",
"Max Mel Tokens Help": "Controls the maximum mel tokens generated in one request. Higher values can produce longer audio.", "Max Mel Tokens Help": "Controls the maximum mel tokens generated in one request. Higher values can produce longer audio.",
"IndexTTS2 Usage Instructions Title": "IndexTTS-2 Usage Instructions", "IndexTTS2 Usage Instructions Title": "IndexTTS-2 Usage Instructions",
"IndexTTS2 Usage Instructions": "**IndexTTS-2 voice cloning**\n\n1. **Choose a voice**: reuse IndexTTS-1.5 resource audio or upload a reference audio file\n2. **Set API URL**: for example http://192.168.3.6:7863/tts, or enter the service root\n3. **Tune emotion**: speaker is the default; switch to audio, vector, or text when needed\n4. **Tune generation**: temperature, top_p, top_k, num_beams, repetition_penalty, and max_mel_tokens are sent directly to the IndexTTS-2 API\n\n**Notes**:\n- Reference audio quality directly affects cloning quality\n- The first request may load the model and take longer\n- CPU deployments are much slower than GPU deployments", "IndexTTS2 Usage Instructions": "**IndexTTS-2 MLX Pack voice cloning**\n\n1. **Choose a voice**: reuse IndexTTS-1.5 resource audio or upload a reference audio file\n2. **Set API URL**: the default Pack service is http://127.0.0.1:7860; NarratoAI calls /v1/audio/speech/upload and uploads the reference audio\n3. **Tune emotion**: leave it blank to preserve the reference audio emotion, or enter a label/mix such as happy:0.7,calm:0.3\n4. **Tune generation**: speed, temperature, top_p, top_k, repetition_penalty, diffusion_steps, cfg_rate, and max_mel_tokens are sent to the Pack\n\n**Notes**:\n- Reference audio quality directly affects cloning quality\n- The first request may load the model and take longer\n- The Pack binds to localhost by default",
"OmniVoice Usage Instructions Title": "OmniVoice Usage Instructions", "OmniVoice Usage Instructions Title": "OmniVoice Usage Instructions",
"OmniVoice Usage Instructions": "**OmniVoice-Pack speech synthesis**\n\n1. **Automatic voice**: set the API URL and language, then synthesize directly.\n2. **Voice design**: fill instruct with the desired gender, pitch, accent, or style.\n3. **Reference-audio clone**: upload or choose reference audio and fill its matching transcript.\n\n**Notes**:\n- The default service URL is http://127.0.0.1:7866/tts\n- Reference-audio cloning requires reference text when the service has no ASR model loaded\n- OmniVoice returns WAV audio, and NarratoAI estimates subtitle segment timing from the audio duration", "OmniVoice Usage Instructions": "**OmniVoice-Pack speech synthesis**\n\n1. **Automatic voice**: set the API URL and language, then synthesize directly.\n2. **Voice design**: fill instruct with the desired gender, pitch, accent, or style.\n3. **Reference-audio clone**: upload or choose reference audio and fill its matching transcript.\n\n**Notes**:\n- The default service URL is http://127.0.0.1:7866/tts\n- Reference-audio cloning requires reference text when the service has no ASR model loaded\n- OmniVoice returns WAV audio, and NarratoAI estimates subtitle segment timing from the audio duration",
"Volcengine Access Key Help": "Volcengine Access Key", "Volcengine Access Key Help": "Volcengine Access Key",
"Volcengine Secret Key Help": "Volcengine Secret Key", "Volcengine Secret Key Help": "Volcengine Secret Key",
"Doubao API Key Help": "New Doubao Speech API Key. This field is preferred and does not require AppID or Token.",
"Doubao Legacy Credentials": "Legacy AppID / Token Credentials",
"Doubao AppID Help": "Doubao TTS application AppID", "Doubao AppID Help": "Doubao TTS application AppID",
"Doubao Token Help": "Doubao TTS application Token", "Doubao Token Help": "Doubao TTS application Token",
"Cluster": "Cluster", "Cluster": "Cluster",
@ -656,13 +734,13 @@
"Sentence Silence Duration Help": "Adjust sentence-end silence duration (0.0-2.0 seconds)", "Sentence Silence Duration Help": "Adjust sentence-end silence duration (0.0-2.0 seconds)",
"Doubao TTS API Key Application Process": "Doubao TTS API Key Application Process", "Doubao TTS API Key Application Process": "Doubao TTS API Key Application Process",
"Application Steps": "Application Steps", "Application Steps": "Application Steps",
"Doubao TTS Step 1": "1. Open [https://console.volcengine.com/iam/keymanage](https://console.volcengine.com/iam/keymanage)", "Doubao TTS Step 1": "1. Open the Volcengine Doubao Speech console",
"Doubao TTS Step 2": "2. Create a new Access Key and Secret Key", "Doubao TTS Step 2": "2. Open API Key management and create an API Key",
"Doubao TTS Step 3": "3. Open [https://www.volcengine.com/product/voice-tech](https://www.volcengine.com/product/voice-tech)", "Doubao TTS Step 3": "3. Make sure Doubao speech synthesis is enabled",
"Doubao TTS Step 4": "4. Click Start Now", "Doubao TTS Step 4": "4. Copy the API Key into the API Key field above",
"Doubao TTS Step 5": "5. In the left API Service Center, find Speech Synthesis under Audio Generation (note: Speech Synthesis, not the speech synthesis large model)", "Doubao TTS Step 5": "5. The default cluster is volcano_tts and usually does not need changes",
"Doubao TTS Step 6": "6. Scroll to the bottom to get the APPID and Access Token", "Doubao TTS Step 6": "6. Legacy AppID/Token users can keep using the compatibility fields",
"Doubao TTS Fill Credentials Notice": "Fill the Access Key, Secret Key, AppID, and Token above.", "Doubao TTS Fill Credentials Notice": "The new setup only requires an API Key. Legacy AppID/Token credentials remain supported.",
"Doubao TTS configured": "Doubao TTS is configured", "Doubao TTS configured": "Doubao TTS is configured",
"Please configure missing fields": "Please configure: {fields}", "Please configure missing fields": "Please configure: {fields}",
"Preview Voice Synthesis": "Preview Voice Synthesis", "Preview Voice Synthesis": "Preview Voice Synthesis",

View File

@ -42,7 +42,21 @@
"Custom Background Music": "自定义背景音乐", "Custom Background Music": "自定义背景音乐",
"Custom Background Music File": "请输入自定义背景音乐的文件路径", "Custom Background Music File": "请输入自定义背景音乐的文件路径",
"Background Music Source": "背景音乐来源", "Background Music Source": "背景音乐来源",
"Background Music Source Help": "选择资源目录中的背景音乐、上传新的背景音乐,或关闭背景音乐", "Background Music Source Help": "选择资源目录中的背景音乐、上传新的背景音乐、使用 Sonilo 根据画面生成配乐,或关闭背景音乐",
"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 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": "上传背景音乐", "Upload Background Music": "上传背景音乐",
"Background Music Path Help": "选择用于视频合成的背景音乐", "Background Music Path Help": "选择用于视频合成的背景音乐",
"No Background Music Resources Found": "未找到资源目录中的背景音乐,请上传背景音乐文件", "No Background Music Resources Found": "未找到资源目录中的背景音乐,请上传背景音乐文件",
@ -186,6 +200,10 @@
"Text API Key": "文案生成 API 密钥", "Text API Key": "文案生成 API 密钥",
"Text Base URL": "文案生成接口地址", "Text Base URL": "文案生成接口地址",
"Text Model Name": "文案生成模型名称", "Text Model Name": "文案生成模型名称",
"High Reasoning Model Name": "高推理模型名称",
"High Efficiency Model Name": "高效率模型名称",
"High Reasoning Model Help": "用于剧情分析、文案生成、脚本生成与匹配等复杂任务。",
"High Efficiency Model Help": "用于字幕翻译、字幕校准等批量任务;留空时自动使用高推理模型。",
"Top P": "Top P", "Top P": "Top P",
"Top K": "Top K", "Top K": "Top K",
"Max Output Tokens": "最大输出 Token", "Max Output Tokens": "最大输出 Token",
@ -311,13 +329,19 @@
"Tencent Cloud TTS use case": "个人和企业用户,需要稳定的中文语音合成", "Tencent Cloud TTS use case": "个人和企业用户,需要稳定的中文语音合成",
"Tongyi Qwen3 TTS features": "阿里云通义千问语音合成,音质优秀,支持多种音色", "Tongyi Qwen3 TTS features": "阿里云通义千问语音合成,音质优秀,支持多种音色",
"High-quality Chinese speech synthesis use case": "需要高质量中文语音合成的用户", "High-quality Chinese speech synthesis use case": "需要高质量中文语音合成的用户",
"IndexTTS features": "本地/私有部署的 IndexTTS-1.5 语音克隆引擎。选择资源目录音频或上传参考音频后,可按该音色合成旁白。", "IndexTTS features": "Windows 本地/私有部署的 IndexTTS-1.5 语音克隆引擎。选择资源目录音频或上传参考音频后,可按该音色合成旁白。",
"IndexTTS use case": "适合需要固定旁白音色、角色配音或批量生成同一音色视频的场景。使用前请先启动 IndexTTS-1.5 API 服务部署包下载https://pan.quark.cn/s/0767c9bcefd5", "IndexTTS use case": "适合在 Windows 上生成固定旁白音色、角色配音或批量生成同一音色视频。使用前请先启动 IndexTTS-1.5 API 服务;[下载部署包](https://cutagent.online/resources/indextts15-windows)。",
"IndexTTS download link": "下载地址https://pan.quark.cn/s/0767c9bcefd5", "IndexTTS download link": "下载地址:\n\n- **macOS**[下载地址](https://cutagent.online/resources/indextts15-mlx-macos)\n- **Windows**[下载地址](https://cutagent.online/resources/indextts15-windows)",
"IndexTTS2 features": "本地/私有部署的 IndexTTS-2 语音克隆引擎,支持情感控制和更完整的生成参数。", "IndexTTS macOS features": "面向 Apple Silicon 的 IndexTTS-1.5 MLX 本地语音克隆引擎,通过上传参考音频合成旁白。",
"IndexTTS2 use case": "适合需要固定音色、情绪化旁白或更细致采样控制的本地语音合成场景。使用前请先启动 IndexTTS-2 API 服务。", "IndexTTS macOS use case": "适合在 Apple Silicon Mac 上本地生成固定旁白音色和角色配音。使用前请启动 IndexTTS-MLX-1.5-Pack[下载部署包](https://cutagent.online/resources/indextts15-mlx-macos)。",
"IndexTTS2 features": "本地部署的 IndexTTS-2 MLX Pack 语音克隆引擎,支持情感控制和更完整的生成参数。",
"IndexTTS2 use case": "适合需要固定音色、情绪化旁白或更细致采样控制的本地语音合成场景。使用前请先启动 IndexTTS-2 MLX Pack 服务;部署包下载:\n\n- **macOS**[下载地址](https://cutagent.online/resources/indextts2-full-macos)\n- **Windows**:待更新",
"OmniVoice features": "本地/私有部署的 OmniVoice-Pack 多语种语音合成引擎,支持自动音色、指令音色和参考音频克隆。", "OmniVoice features": "本地/私有部署的 OmniVoice-Pack 多语种语音合成引擎,支持自动音色、指令音色和参考音频克隆。",
"OmniVoice use case": "适合需要本地可控、多语言旁白、音色设计或参考音频克隆的场景。使用前请先启动 OmniVoice-Pack API 服务。", "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": "火山引擎豆包语音合成,支持多种音色和情感,国内访问速度快", "Doubao TTS features": "火山引擎豆包语音合成,支持多种音色和情感,国内访问速度快",
"Local Deployment": "本地部署", "Local Deployment": "本地部署",
"Cloud Service": "云端服务", "Cloud Service": "云端服务",
@ -411,6 +435,7 @@
"Characters": "字符", "Characters": "字符",
"Ali Bailian Fun-ASR Subtitle Transcription": "字幕处理", "Ali Bailian Fun-ASR Subtitle Transcription": "字幕处理",
"Subtitle Processing Method": "字幕处理方式", "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 后端", "Fun-ASR Backend": "Fun-ASR 后端",
"Local FunASR-Pack API": "FunASR(本地部署)", "Local FunASR-Pack API": "FunASR(本地部署)",
"Local FireRedASR API": "FireRedASR2(本地部署)", "Local FireRedASR API": "FireRedASR2(本地部署)",
@ -422,7 +447,7 @@
"Auto Transcription FireRed Caption": "将在最终视频合并完成后,通过本机运行的 FireRedASR2-AED-Pack API 生成 SRT 字幕。", "Auto Transcription FireRed Caption": "将在最终视频合并完成后,通过本机运行的 FireRedASR2-AED-Pack API 生成 SRT 字幕。",
"Auto Transcription Online Caption": "将在最终视频合并完成后,自动上传到阿里百炼临时存储并通过 fun-asr 生成 SRT 字幕。", "Auto Transcription Online Caption": "将在最终视频合并完成后,自动上传到阿里百炼临时存储并通过 fun-asr 生成 SRT 字幕。",
"Local FunASR-Pack API URL": "本地 FunASR-Pack API 地址", "Local FunASR-Pack API URL": "本地 FunASR-Pack API 地址",
"Local FunASR-Pack API URL Help": "例如 http://127.0.0.1:7860也可以直接填到 /asr 的完整地址。", "Local FunASR-Pack API URL Help": "例如 http://127.0.0.1:7860也可以直接填写 /asr、/v1 或 /v1/audio/transcriptions 的完整地址。",
"Local FireRedASR API URL": "本地ASR API 地址", "Local FireRedASR API URL": "本地ASR API 地址",
"Local FireRedASR API URL Help": "例如 http://127.0.0.1:7867也可以直接填到 /asr 的完整地址。", "Local FireRedASR API URL Help": "例如 http://127.0.0.1:7867也可以直接填到 /asr 的完整地址。",
"Fun-ASR Hotword": "热词", "Fun-ASR Hotword": "热词",
@ -440,6 +465,9 @@
"Selected video files do not exist": "以下视频文件不存在,请重新选择或上传: {files}", "Selected video files do not exist": "以下视频文件不存在,请重新选择或上传: {files}",
"Transcribe subtitles": "转录字幕", "Transcribe subtitles": "转录字幕",
"Calibrate subtitles": "校准字幕", "Calibrate subtitles": "校准字幕",
"Translate subtitles": "翻译字幕",
"Subtitle target language": "目标语言",
"Subtitle target language placeholder": "中文",
"Please enter Ali Bailian API Key": "请先输入阿里百炼 API Key", "Please enter Ali Bailian API Key": "请先输入阿里百炼 API Key",
"Please enter local FunASR-Pack API URL": "请先输入本地 FunASR-Pack API 地址", "Please enter local FunASR-Pack API URL": "请先输入本地 FunASR-Pack API 地址",
"Please enter local FireRedASR API URL": "请先输入本地ASR API 地址", "Please enter local FireRedASR API URL": "请先输入本地ASR API 地址",
@ -454,6 +482,11 @@
"Subtitle calibration succeeded": "字幕校准成功: {file}", "Subtitle calibration succeeded": "字幕校准成功: {file}",
"Subtitle calibration succeeded for multiple files": "字幕校准成功,共 {count} 个文件: {files}", "Subtitle calibration succeeded for multiple files": "字幕校准成功,共 {count} 个文件: {files}",
"Subtitle calibration failed": "字幕校准失败", "Subtitle calibration failed": "字幕校准失败",
"Translating subtitles...": "正在使用大模型翻译字幕为 {language},请稍候...",
"Subtitle translation succeeded": "字幕翻译成功: {file}",
"Subtitle translation succeeded for multiple files": "字幕翻译成功,共 {count} 个文件: {files}",
"Subtitle translation failed": "字幕翻译失败",
"Subtitle translation progress": "{file}: {completed}/{total} 条 · {message}",
"Transcribed subtitles storage hint": "之前转录生成的字幕保存在 {path},可从该目录拖入上传", "Transcribed subtitles storage hint": "之前转录生成的字幕保存在 {path},可从该目录拖入上传",
"Tavily Search Settings": "Tavily 联网搜索", "Tavily Search Settings": "Tavily 联网搜索",
"Tavily API Key": "Tavily API Key", "Tavily API Key": "Tavily API Key",
@ -495,8 +528,30 @@
"Select Qwen3 TTS Voice": "选择 Qwen3 TTS 音色", "Select Qwen3 TTS Voice": "选择 Qwen3 TTS 音色",
"API URL": "API 地址", "API URL": "API 地址",
"IndexTTS API URL Help": "IndexTTS-1.5 API 服务地址", "IndexTTS API URL Help": "IndexTTS-1.5 API 服务地址",
"IndexTTS2 API URL Help": "IndexTTS-2 API 服务地址,可填写服务根地址或完整 /tts 地址", "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 地址", "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": "合成语言",
"OmniVoice Language Code Help": "传给 OmniVoice-Pack 的 language 参数,例如 zh、en。", "OmniVoice Language Code Help": "传给 OmniVoice-Pack 的 language 参数,例如 zh、en。",
"OmniVoice Generation Mode": "生成模式", "OmniVoice Generation Mode": "生成模式",
@ -549,7 +604,18 @@
"Enable Sampling Help": "启用采样可以获得更自然的语音", "Enable Sampling Help": "启用采样可以获得更自然的语音",
"IndexTTS Usage Instructions Title": "IndexTTS-1.5 使用说明", "IndexTTS Usage Instructions Title": "IndexTTS-1.5 使用说明",
"IndexTTS Usage Instructions": "**零样本语音克隆**\n\n1. **准备参考音频**:上传或指定一段清晰的音频文件(建议 3-10 秒)\n2. **设置 API 地址**:确保 IndexTTS-1.5 服务正常运行\n3. **开始合成**:系统会自动使用参考音频的音色合成新语音\n\n**注意事项**\n- 参考音频质量直接影响合成效果\n- 建议使用无背景噪音的清晰音频\n- 文本长度建议控制在合理范围内\n- 首次合成可能需要较长时间", "IndexTTS Usage Instructions": "**零样本语音克隆**\n\n1. **准备参考音频**:上传或指定一段清晰的音频文件(建议 3-10 秒)\n2. **设置 API 地址**:确保 IndexTTS-1.5 服务正常运行\n3. **开始合成**:系统会自动使用参考音频的音色合成新语音\n\n**注意事项**\n- 参考音频质量直接影响合成效果\n- 建议使用无背景噪音的清晰音频\n- 文本长度建议控制在合理范围内\n- 首次合成可能需要较长时间",
"IndexTTS macOS Usage Instructions Title": "IndexTTS-1.5-macOS 使用说明",
"IndexTTS macOS Usage Instructions": "**Apple Silicon 本地语音克隆**\n\n1. 双击 Pack 中的 `start.command` 启动服务\n2. 保持默认 API 地址 `http://127.0.0.1:7866`,或填写完整上传接口\n3. 选择清晰的参考音频后开始合成\n\n首次请求需要加载 MLX 模型,耗时通常更长。该引擎仅适用于 Apple Silicon Mac。",
"IndexTTS2 Emotion Parameters": "情感参数", "IndexTTS2 Emotion Parameters": "情感参数",
"IndexTTS2 Emotion": "情感覆盖",
"IndexTTS2 Emotion Help": "留空时保留参考音频的情感;可填写单个情感,如 happy或权重混合如 happy:0.7,calm:0.3。",
"IndexTTS2 Emotion Placeholder": "例如happy:0.7,calm:0.3",
"IndexTTS2 Emotion Audio Unsupported": "IndexTTS-2 MLX Pack 不支持单独的情感参考音频,将使用音色参考音频的情感。",
"IndexTTS2 Speed": "语速",
"IndexTTS2 Speed Help": "传给 IndexTTS-2 MLX Pack 的语速0.5x2.0x)。",
"IndexTTS2 Seed": "随机种子",
"IndexTTS2 Seed Help": "可选整数,用于复现生成结果;留空则随机采样。",
"IndexTTS2 Seed Placeholder": "随机",
"Emotion Mode": "情感控制方式", "Emotion Mode": "情感控制方式",
"Emotion Mode Help": "选择 IndexTTS-2 的情感控制来源", "Emotion Mode Help": "选择 IndexTTS-2 的情感控制来源",
"Emotion Mode Speaker": "与音色参考相同", "Emotion Mode Speaker": "与音色参考相同",
@ -573,16 +639,27 @@
"Emotion Melancholic": "忧郁", "Emotion Melancholic": "忧郁",
"Emotion Surprised": "惊讶", "Emotion Surprised": "惊讶",
"Emotion Calm": "平静", "Emotion Calm": "平静",
"Diffusion Steps": "扩散步数",
"Diffusion Steps Help": "扩散生成的步数;值越大可能提升质量,但生成更慢。",
"CFG Rate": "CFG 引导强度",
"CFG Rate Help": "扩散阶段的 classifier-free guidance 强度。",
"Interval Silence": "分段静音(毫秒)",
"Interval Silence Help": "Pack 在文本分段之间插入的静音时长。",
"Segment Overlap": "分段重叠(毫秒)",
"Segment Overlap Help": "生成的文本片段之间用于交叉淡化的重叠时长。",
"IndexTTS2 Top K Help": "IndexTTS-2 MLX Pack 的 top-k 采样值,必须不小于 1。",
"Max Text Tokens Per Segment": "单段最大文本 Token", "Max Text Tokens Per Segment": "单段最大文本 Token",
"Max Text Tokens Per Segment Help": "IndexTTS-2 分段推理的最大文本 token 数", "Max Text Tokens Per Segment Help": "IndexTTS-2 分段推理的最大文本 token 数",
"Max Mel Tokens": "最大 Mel Tokens", "Max Mel Tokens": "最大 Mel Tokens",
"Max Mel Tokens Help": "控制单次生成的最大 mel token 数,值越大可生成更长音频", "Max Mel Tokens Help": "控制单次生成的最大 mel token 数,值越大可生成更长音频",
"IndexTTS2 Usage Instructions Title": "IndexTTS-2 使用说明", "IndexTTS2 Usage Instructions Title": "IndexTTS-2 使用说明",
"IndexTTS2 Usage Instructions": "**IndexTTS-2 语音克隆**\n\n1. **选择音色**:复用 IndexTTS-1.5 的资源音频或上传参考音频\n2. **设置 API 地址**:例如 http://192.168.3.6:7863/tts也可以填写服务根地址\n3. **调整情感参数**:默认使用 speaker可按需切换到 audio、vector 或 text\n4. **调整生成参数**temperature、top_p、top_k、num_beams、repetition_penalty 和 max_mel_tokens 会直接传给 IndexTTS-2 接口\n\n**注意事项**\n- 参考音频质量会直接影响克隆效果\n- 首次请求可能需要加载模型,耗时更长\n- CPU 部署生成速度会明显慢于 GPU", "IndexTTS2 Usage Instructions": "**IndexTTS-2 MLX Pack 语音克隆**\n\n1. **选择音色**:复用 IndexTTS-1.5 的资源音频或上传参考音频\n2. **设置 API 地址**默认 Pack 服务为 http://127.0.0.1:7860NarratoAI 会调用 /v1/audio/speech/upload 并上传参考音频\n3. **调整情感参数**:留空时保留参考音频情感;也可输入 happy:0.7,calm:0.3 这类情感或权重混合\n4. **调整生成参数**speed、temperature、top_p、top_k、repetition_penalty、diffusion_steps、cfg_rate 和 max_mel_tokens 会传给 Pack\n\n**注意事项**\n- 参考音频质量会直接影响克隆效果\n- 首次请求可能需要加载模型,耗时更长\n- Pack 默认仅监听本机地址",
"OmniVoice Usage Instructions Title": "OmniVoice 使用说明", "OmniVoice Usage Instructions Title": "OmniVoice 使用说明",
"OmniVoice Usage Instructions": "**OmniVoice-Pack 语音合成**\n\n1. **自动音色**:只需要设置 API 地址和语言,可直接合成。\n2. **指令音色**:填写 instruct 描述想要的性别、音高、口音或风格。\n3. **参考音频克隆**:上传或选择参考音频,并填写该音频对应文本。\n\n**注意事项**\n- 当前默认服务地址为 http://127.0.0.1:7866/tts\n- 参考音频克隆在服务未加载 ASR 模型时必须填写参考文本\n- OmniVoice 返回 WAV 音频,系统会按音频时长估算字幕段落", "OmniVoice Usage Instructions": "**OmniVoice-Pack 语音合成**\n\n1. **自动音色**:只需要设置 API 地址和语言,可直接合成。\n2. **指令音色**:填写 instruct 描述想要的性别、音高、口音或风格。\n3. **参考音频克隆**:上传或选择参考音频,并填写该音频对应文本。\n\n**注意事项**\n- 当前默认服务地址为 http://127.0.0.1:7866/tts\n- 参考音频克隆在服务未加载 ASR 模型时必须填写参考文本\n- OmniVoice 返回 WAV 音频,系统会按音频时长估算字幕段落",
"Volcengine Access Key Help": "火山引擎 Access Key", "Volcengine Access Key Help": "火山引擎 Access Key",
"Volcengine Secret Key Help": "火山引擎 Secret Key", "Volcengine Secret Key Help": "火山引擎 Secret Key",
"Doubao API Key Help": "新版豆包语音 API Key优先使用该字段无需填写 AppID 和 Token",
"Doubao Legacy Credentials": "旧版 AppID / Token 配置(兼容)",
"Doubao AppID Help": "豆包语音应用 AppID", "Doubao AppID Help": "豆包语音应用 AppID",
"Doubao Token Help": "豆包语音应用 Token", "Doubao Token Help": "豆包语音应用 Token",
"Cluster": "集群", "Cluster": "集群",
@ -595,13 +672,13 @@
"Sentence Silence Duration Help": "调节句尾静音时长 (0.0-2.0 秒)", "Sentence Silence Duration Help": "调节句尾静音时长 (0.0-2.0 秒)",
"Doubao TTS API Key Application Process": "豆包语音 TTS API Key申请流程", "Doubao TTS API Key Application Process": "豆包语音 TTS API Key申请流程",
"Application Steps": "申请步骤", "Application Steps": "申请步骤",
"Doubao TTS Step 1": "1. 打开 [https://console.volcengine.com/iam/keymanage](https://console.volcengine.com/iam/keymanage)", "Doubao TTS Step 1": "1. 打开火山引擎豆包语音控制台",
"Doubao TTS Step 2": "2. 新建 Access Key 和 Secret Key", "Doubao TTS Step 2": "2. 进入 API Key 管理并创建 API Key",
"Doubao TTS Step 3": "3. 打开 [https://www.volcengine.com/product/voice-tech](https://www.volcengine.com/product/voice-tech)", "Doubao TTS Step 3": "3. 确认已开通豆包语音合成服务",
"Doubao TTS Step 4": "4. 点击立即使用", "Doubao TTS Step 4": "4. 复制 API Key 并填写到上方 API Key 输入框",
"Doubao TTS Step 5": "5. 在最左边的 API 服务中心找到音频生成下面的语音合成(注意:是语音合成,不是语音合成大模型)", "Doubao TTS Step 5": "5. 默认集群使用 volcano_tts通常无需修改",
"Doubao TTS Step 6": "6. 翻到最下面获取 APPID 和 Access Token", "Doubao TTS Step 6": "6. 旧版 AppID/Token 用户可继续在兼容配置中填写原凭据",
"Doubao TTS Fill Credentials Notice": "请将获取到的 Access Key、Secret Key、AppID 和 Token 填写到上方的配置中", "Doubao TTS Fill Credentials Notice": "新版配置只需要填写 API Key旧版 AppID/Token 仍保留兼容",
"Doubao TTS configured": "豆包语音 TTS 配置已设置", "Doubao TTS configured": "豆包语音 TTS 配置已设置",
"Please configure missing fields": "请配置: {fields}", "Please configure missing fields": "请配置: {fields}",
"Preview Voice Synthesis": "试听语音合成", "Preview Voice Synthesis": "试听语音合成",
@ -636,6 +713,7 @@
"影视类型": "影视类型", "影视类型": "影视类型",
"自定义影视类型": "自定义影视类型", "自定义影视类型": "自定义影视类型",
"原片占比": "原片占比", "原片占比": "原片占比",
"文案字数": "文案字数",
"例如:豪门虐恋": "例如:豪门虐恋", "例如:豪门虐恋": "例如:豪门虐恋",
"例如:悬疑犯罪": "例如:悬疑犯罪", "例如:悬疑犯罪": "例如:悬疑犯罪",
"请输入自定义短剧类型": "请输入自定义短剧类型", "请输入自定义短剧类型": "请输入自定义短剧类型",

View File

@ -8,9 +8,48 @@ from loguru import logger
from app.config import config from app.config import config
from app.services.upload_validation import ensure_existing_file, InputValidationError from app.services.upload_validation import ensure_existing_file, InputValidationError
from app.utils import utils from app.utils import utils
from webui.tools.generate_short_summary import (
SHORT_DRAMA_PROMPT_CATEGORY,
SHORT_DRAMA_SEARCH_KEYWORDS,
_build_combined_subtitle_content,
_normalize_paths,
analyze_short_drama_plot,
parse_and_fix_json,
)
def generate_script_short(tr, params, custom_clips=5): def _parse_generated_script_payload(script):
if isinstance(script, list):
return script
if isinstance(script, str):
parsed = parse_and_fix_json(script)
if isinstance(parsed, list):
return parsed
if isinstance(parsed, dict) and isinstance(parsed.get("items"), list):
return parsed["items"]
raise ValueError("Generated script JSON must be a list or contain an items list")
raise ValueError("Generated script payload must be a list or JSON string")
def generate_script_short(
tr,
params,
custom_clips=5,
subtitle_paths=None,
video_theme=None,
temperature=0.7,
plot_analysis=None,
subtitle_content=None,
enable_web_search=False,
video_paths=None,
drama_genre="逆袭/复仇",
prompt_category=SHORT_DRAMA_PROMPT_CATEGORY,
search_keywords=SHORT_DRAMA_SEARCH_KEYWORDS,
empty_title_message_key="Please enter short drama name before web search",
web_search_context_description="短剧名称、人物关系、剧情背景和公开剧情梗概",
):
""" """
生成短视频脚本 生成短视频脚本
@ -18,6 +57,14 @@ def generate_script_short(tr, params, custom_clips=5):
tr: 翻译函数 tr: 翻译函数
params: 视频参数对象 params: 视频参数对象
custom_clips: 自定义片段数量默认为5 custom_clips: 自定义片段数量默认为5
subtitle_paths: 已转写/上传/翻译/校准后的字幕路径列表
video_theme: 短剧名称
temperature: LLM温度
plot_analysis: 已完成的剧情理解文本
subtitle_content: 已合并的字幕文本
enable_web_search: 是否在剧情理解前联网搜索
video_paths: 原始视频路径列表
drama_genre: 用户选择的短剧类型
""" """
progress_bar = st.progress(0) progress_bar = st.progress(0)
status_text = st.empty() status_text = st.empty()
@ -33,11 +80,16 @@ def generate_script_short(tr, params, custom_clips=5):
with st.spinner(tr("Generating script...")): with st.spinner(tr("Generating script...")):
# ========== 严格验证:必须上传视频和字幕(与短剧解说保持一致)========== # ========== 严格验证:必须上传视频和字幕(与短剧解说保持一致)==========
# 1. 验证视频文件 # 1. 验证视频文件
video_path = getattr(params, "video_origin_path", None) selected_video_paths = _normalize_paths(
if not video_path or not str(video_path).strip(): video_paths
or getattr(params, "video_origin_paths", [])
or getattr(params, "video_origin_path", "")
)
if not selected_video_paths:
st.error(tr("Please select video file first")) st.error(tr("Please select video file first"))
st.stop() st.stop()
for video_path in selected_video_paths:
try: try:
ensure_existing_file( ensure_existing_file(
str(video_path), str(video_path),
@ -49,22 +101,26 @@ def generate_script_short(tr, params, custom_clips=5):
st.stop() st.stop()
# 2. 验证字幕文件(移除推断逻辑,必须上传) # 2. 验证字幕文件(移除推断逻辑,必须上传)
subtitle_path = st.session_state.get("subtitle_path") subtitle_paths = _normalize_paths(subtitle_paths or st.session_state.get("subtitle_paths") or st.session_state.get("subtitle_path"))
if not subtitle_path or not str(subtitle_path).strip(): if not subtitle_paths:
st.error(tr("Please upload subtitle file first")) st.error(tr("Please upload subtitle file first"))
st.stop() st.stop()
validated_subtitle_paths = []
try: try:
subtitle_path = ensure_existing_file( for subtitle_path in subtitle_paths:
validated_subtitle_paths.append(
ensure_existing_file(
str(subtitle_path), str(subtitle_path),
label=tr("Subtitle"), label=tr("Subtitle"),
allowed_exts=(".srt",), allowed_exts=(".srt",),
) )
)
except InputValidationError as e: except InputValidationError as e:
st.error(str(e)) st.error(str(e))
st.stop() st.stop()
logger.info(f"使用用户上传的字幕文件: {subtitle_path}") logger.info(f"使用用户处理后的字幕文件: {validated_subtitle_paths}")
# ========== 获取 LLM 配置 ========== # ========== 获取 LLM 配置 ==========
text_provider = config.app.get('text_llm_provider', 'gemini').lower() text_provider = config.app.get('text_llm_provider', 'gemini').lower()
@ -80,18 +136,40 @@ def generate_script_short(tr, params, custom_clips=5):
update_progress(20, tr("Preparing script generation")) update_progress(20, tr("Preparing script generation"))
subtitle_content = str(subtitle_content or "").strip() or _build_combined_subtitle_content(
validated_subtitle_paths,
selected_video_paths,
)
if not subtitle_content:
st.error(tr("Subtitle file is empty or unreadable"))
st.stop()
plot_analysis = str(plot_analysis or "").strip()
if not plot_analysis:
update_progress(35, tr("Analyzing subtitles with model..."))
plot_analysis = analyze_short_drama_plot(
validated_subtitle_paths,
temperature,
tr,
subtitle_content=subtitle_content,
short_name=video_theme,
enable_web_search=enable_web_search,
video_paths=selected_video_paths,
prompt_category=prompt_category,
search_keywords=search_keywords,
empty_title_message_key=empty_title_message_key,
web_search_context_description=web_search_context_description,
)
if not plot_analysis:
st.error(tr("Script generation failed check logs"))
st.stop()
# ========== 调用后端生成脚本 ========== # ========== 调用后端生成脚本 ==========
from app.services.SDP.generate_script_short import generate_script_result from app.services.SDP.generate_script_short import generate_script_result
output_path = os.path.join(utils.script_dir(), "merged_subtitle.json") output_path = os.path.join(utils.script_dir(), "merged_subtitle.json")
subtitle_content = st.session_state.get("subtitle_content") update_progress(55, tr("Generating script..."))
subtitle_kwargs = (
{"subtitle_content": str(subtitle_content)}
if subtitle_content is not None and str(subtitle_content).strip()
else {"subtitle_file_path": subtitle_path}
)
result = generate_script_result( result = generate_script_result(
api_key=text_api_key, api_key=text_api_key,
model_name=text_model, model_name=text_model,
@ -99,7 +177,11 @@ def generate_script_short(tr, params, custom_clips=5):
base_url=text_base_url, base_url=text_base_url,
custom_clips=custom_clips, custom_clips=custom_clips,
provider=text_provider, provider=text_provider,
**subtitle_kwargs, subtitle_content=subtitle_content,
video_paths=selected_video_paths,
plot_analysis=plot_analysis,
short_name=video_theme or "",
drama_genre=drama_genre or "",
) )
if result.get("status") != "success": if result.get("status") != "success":
@ -109,10 +191,7 @@ def generate_script_short(tr, params, custom_clips=5):
script = result.get("script") script = result.get("script")
logger.info(f"脚本生成完成 {json.dumps(script, ensure_ascii=False, indent=4)}") logger.info(f"脚本生成完成 {json.dumps(script, ensure_ascii=False, indent=4)}")
if isinstance(script, list): st.session_state['video_clip_json'] = _parse_generated_script_payload(script)
st.session_state['video_clip_json'] = script
elif isinstance(script, str):
st.session_state['video_clip_json'] = json.loads(script)
update_progress(80, tr("Script generation completed")) update_progress(80, tr("Script generation completed"))
@ -120,8 +199,14 @@ def generate_script_short(tr, params, custom_clips=5):
progress_bar.progress(100) progress_bar.progress(100)
status_text.text(tr("Script generation completed!")) status_text.text(tr("Script generation completed!"))
st.success(tr("Video script generated successfully")) st.success(tr("Video script generated successfully"))
return {
"script": st.session_state.get('video_clip_json', []),
"plot_analysis": plot_analysis,
"subtitle_content": subtitle_content,
}
except Exception as err: except Exception as err:
progress_bar.progress(100) progress_bar.progress(100)
st.error(f"{tr('Generation error')}: {str(err)}") st.error(f"{tr('Generation error')}: {str(err)}")
logger.exception(f"生成脚本时发生错误\n{traceback.format_exc()}") logger.exception(f"生成脚本时发生错误\n{traceback.format_exc()}")
return None

View File

@ -176,8 +176,8 @@ def parse_and_fix_json(json_string):
# 5. 修复单引号 # 5. 修复单引号
fixed_json = re.sub(r"'([^']*)':", r'"\1":', fixed_json) fixed_json = re.sub(r"'([^']*)':", r'"\1":', fixed_json)
# 6. 修复没有引号的属性名 # 6. 修复没有引号的属性名,仅匹配对象边界后的 key避免误伤时间戳等字符串值
fixed_json = re.sub(r'(\w+)(\s*):', r'"\1"\2:', fixed_json) fixed_json = re.sub(r'([{\[,]\s*)([A-Za-z_][A-Za-z0-9_]*)(\s*:)', r'\1"\2"\3', fixed_json)
# 7. 修复重复的引号 # 7. 修复重复的引号
fixed_json = re.sub(r'""([^"]*?)""', r'"\1"', fixed_json) fixed_json = re.sub(r'""([^"]*?)""', r'"\1"', fixed_json)
@ -363,6 +363,7 @@ def generate_short_drama_narration_copy(
search_keywords: str = SHORT_DRAMA_SEARCH_KEYWORDS, search_keywords: str = SHORT_DRAMA_SEARCH_KEYWORDS,
empty_title_message_key: str = "Please enter short drama name before web search", empty_title_message_key: str = "Please enter short drama name before web search",
web_search_context_description: str = "短剧名称、人物关系、剧情背景和公开剧情梗概", web_search_context_description: str = "短剧名称、人物关系、剧情背景和公开剧情梗概",
narration_word_count: int = 500,
): ):
"""生成可由用户审核修改的短剧解说正文,不绑定时间戳。""" """生成可由用户审核修改的短剧解说正文,不绑定时间戳。"""
subtitle_paths = _normalize_paths(subtitle_path) subtitle_paths = _normalize_paths(subtitle_path)
@ -422,6 +423,7 @@ def generate_short_drama_narration_copy(
temperature=temperature, temperature=temperature,
narration_language=narration_language, narration_language=narration_language,
drama_genre=drama_genre, drama_genre=drama_genre,
narration_word_count=narration_word_count,
) )
except Exception as e: except Exception as e:
logger.warning(f"使用新LLM服务生成文案失败回退到旧实现: {str(e)}") logger.warning(f"使用新LLM服务生成文案失败回退到旧实现: {str(e)}")
@ -436,6 +438,7 @@ def generate_short_drama_narration_copy(
provider=text_provider, provider=text_provider,
narration_language=narration_language, narration_language=narration_language,
drama_genre=drama_genre, drama_genre=drama_genre,
narration_word_count=narration_word_count,
prompt_category=prompt_category, prompt_category=prompt_category,
) )

View File

@ -0,0 +1,38 @@
import unittest
from webui.tools.generate_script_short import _parse_generated_script_payload
class GenerateScriptShortPayloadTests(unittest.TestCase):
def test_parse_generated_script_payload_keeps_list_payload(self):
payload = [{"_id": 1, "timestamp": "00:00:01,000-00:00:02,000"}]
self.assertEqual(payload, _parse_generated_script_payload(payload))
def test_parse_generated_script_payload_accepts_items_wrapper(self):
payload = '{"items": [{"_id": 1, "timestamp": "00:00:01,000-00:00:02,000"}]}'
parsed = _parse_generated_script_payload(payload)
self.assertEqual(1, parsed[0]["_id"])
def test_parse_generated_script_payload_repairs_common_llm_json_formatting(self):
payload = """```json
{
"items": [
{"_id": 1, "timestamp": "00:00:01,000-00:00:02,000",},
],
}
```"""
parsed = _parse_generated_script_payload(payload)
self.assertEqual(1, parsed[0]["_id"])
def test_parse_generated_script_payload_rejects_invalid_shape(self):
with self.assertRaises(ValueError):
_parse_generated_script_payload('{"unexpected": []}')
if __name__ == "__main__":
unittest.main()

View File

@ -22,6 +22,19 @@ class GenerateShortSummaryJsonTests(unittest.TestCase):
self.assertEqual(1, parsed["items"][0]["_id"]) self.assertEqual(1, parsed["items"][0]["_id"])
def test_repair_does_not_corrupt_timestamp_values(self):
parsed = parse_and_fix_json(
"""```json
{
items: [
{_id: 1, timestamp: "00:00:01,000-00:00:02,000",},
],
}
```"""
)
self.assertEqual("00:00:01,000-00:00:02,000", parsed["items"][0]["timestamp"])
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()