Compare commits
51 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a9e17d0e36 | ||
|
|
14828256de | ||
|
|
d46d2ca2a5 | ||
|
|
5ac4488fa3 | ||
|
|
7c2bfbe233 | ||
|
|
0a5dcf5f21 | ||
|
|
022b8bbea3 | ||
|
|
7639c9d289 | ||
|
|
15af56cbd3 | ||
|
|
0c9579c56a | ||
|
|
c09566ae16 | ||
|
|
445feb14ae | ||
|
|
d0216fcb04 | ||
|
|
d29874a163 | ||
|
|
b56222725d | ||
|
|
f80ae97679 | ||
|
|
79c89e64a3 | ||
|
|
8b3812fbea | ||
|
|
39eae0d511 | ||
|
|
cd20aebacf | ||
|
|
990fb36e98 | ||
|
|
2fbd3d0c25 | ||
|
|
4a5b699b11 | ||
|
|
f7be8aafb3 | ||
|
|
0733bb9528 | ||
|
|
f0753cc6e8 | ||
|
|
ec1a1a4c52 | ||
|
|
2d0adcfdec | ||
|
|
8944db7620 | ||
|
|
0407873eff | ||
|
|
4666190181 | ||
|
|
207900a59a | ||
|
|
3eaf61c3f8 | ||
|
|
fd43bf7c0b | ||
|
|
40b91d25b7 | ||
|
|
a6b029a613 | ||
|
|
c288a76ff8 | ||
|
|
0774ac5385 | ||
|
|
23591defb1 | ||
|
|
1b7bd79654 | ||
|
|
d02c848977 | ||
|
|
b15f5807c1 | ||
|
|
18c9ff81d2 | ||
|
|
056521a743 | ||
|
|
d017f3ac75 | ||
|
|
acd27aedc7 | ||
|
|
5f7eed9f85 | ||
|
|
fede336592 | ||
|
|
ed4a5d07e5 | ||
|
|
8b1fcbafa5 | ||
|
|
9f28fcfa98 |
7
.gitignore
vendored
@ -1,6 +1,8 @@
|
||||
.DS_Store
|
||||
/config.toml
|
||||
/storage/
|
||||
.venv/
|
||||
venv/
|
||||
/.idea/
|
||||
/app/services/__pycache__
|
||||
/app/__pycache__/
|
||||
@ -51,6 +53,9 @@ tests/*
|
||||
!tests/test_script_service_documentary_unittest.py
|
||||
!tests/test_generate_narration_script_documentary_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/wechat-0.8
|
||||
docs/wechat-0.8
|
||||
|
||||
1
.python-version
Normal file
@ -0,0 +1 @@
|
||||
3.12
|
||||
20
Dockerfile
@ -15,16 +15,18 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
pkg-config \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# 升级 pip 并创建虚拟环境
|
||||
RUN python -m pip install --upgrade pip setuptools wheel && \
|
||||
python -m venv /opt/venv
|
||||
# 安装 uv 并创建构建虚拟环境
|
||||
RUN python -m pip install --upgrade pip setuptools wheel uv
|
||||
|
||||
# 激活虚拟环境
|
||||
ENV PATH="/opt/venv/bin:$PATH"
|
||||
# 让 uv 将项目依赖同步到运行阶段复用的虚拟环境
|
||||
ENV UV_PROJECT_ENVIRONMENT="/opt/venv" \
|
||||
UV_LINK_MODE=copy \
|
||||
UV_COMPILE_BYTECODE=1 \
|
||||
PATH="/opt/venv/bin:$PATH"
|
||||
|
||||
# 复制 requirements.txt 并使用镜像安装 Python 依赖
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -i https://pypi.tuna.tsinghua.edu.cn/simple -r requirements.txt
|
||||
# 复制 uv 项目文件并安装 Python 依赖
|
||||
COPY pyproject.toml uv.lock ./
|
||||
RUN uv sync --frozen --no-dev
|
||||
|
||||
# 运行阶段
|
||||
FROM python:3.12-slim-bookworm
|
||||
@ -86,4 +88,4 @@ HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
|
||||
|
||||
# 设置入口点
|
||||
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
|
||||
CMD ["webui"]
|
||||
CMD ["webui"]
|
||||
|
||||
40
LICENSE
@ -1,18 +1,16 @@
|
||||
Modified MIT License - Non-Commercial Use Only
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024 linyq
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to use,
|
||||
copy, modify, merge, publish, and distribute the Software, subject to the
|
||||
following conditions:
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
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**.
|
||||
2. Commercial use of the Software, including but not limited to incorporating
|
||||
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 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
|
||||
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,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
|
||||
-------------------------------------------------------
|
||||
中文说明(仅供参考,以英文条款为准):
|
||||
|
||||
修改后的 MIT 协议 - 仅限非商业用途
|
||||
|
||||
版权所有 (c) 2024 linyq
|
||||
|
||||
任何人均可免费获取本软件及相关文档的副本,并享有以下权利:
|
||||
- 使用、复制、修改、合并、发布、分发本软件;
|
||||
|
||||
但必须遵守以下条件:
|
||||
1. 本软件仅限用于 **个人、学习或科研用途**。
|
||||
2. 严禁任何商业用途,包括但不限于将本软件用于收费产品、商业服务、
|
||||
SaaS 平台或任何形式的营利行为。若需商业授权,必须事先获得版权所有者书面许可。
|
||||
3. 上述版权声明及许可条款必须包含在本软件的所有副本或重要部分中。
|
||||
|
||||
免责声明:
|
||||
本软件按“现状”提供,不附带任何明示或暗示的担保,包括但不限于
|
||||
适销性、特定用途适用性和非侵权担保。作者或版权持有人在任何情况下
|
||||
不对因使用本软件或与本软件有关的行为造成的任何损害承担责任。
|
||||
|
||||
13
README-en.md
@ -33,6 +33,7 @@ NarratoAI is an automated video narration tool that provides an all-in-one solut
|
||||
</div>
|
||||
|
||||
## 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
|
||||
- 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
|
||||
@ -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] Released version 0.3.5 integration package
|
||||
- [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] One-click merge materials
|
||||
- [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
|
||||
- [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 📢
|
||||
|
||||
👏 1. You can submit [issue](https://github.com/linyqh/NarratoAI/issues) or [pull request](https://github.com/linyqh/NarratoAI/pulls)
|
||||
|
||||
71
README.md
@ -3,22 +3,12 @@
|
||||
<h3 align="center">一站式 AI 影视解说+自动化剪辑工具🎬🎞️ </h3>
|
||||
|
||||
<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>
|
||||
|
||||
<div align="center">
|
||||
<br>
|
||||
|
||||
> **🔥 隆重推荐:VibeCut 的新范式 —— [speclip.com](https://speclip.com)**
|
||||
>
|
||||
> **一个真正意义上的视频剪辑 Agent!像聊天(vibecoding)一样剪辑视频。**
|
||||
> **[👉 点击立即免费下载 Speclip](https://speclip.com)**
|
||||
|
||||
</div>
|
||||
|
||||
<br>
|
||||
|
||||
NarratoAI 是一款自动化影视解说工具,基于 LLM 实现文案撰写、自动化视频剪辑、配音和字幕生成的一站式流程,助力高效内容创作。支持本地部署开源版及 [云端托管版](https://www.narratoai.cn)。
|
||||
NarratoAI 是一款自动化影视解说工具,基于 LLM 实现文案撰写、自动化视频剪辑、配音和字幕生成的一站式流程,助力高效内容创作。支持本地部署开源版及 [云端托管版](https://www.narratoai.co)。
|
||||
|
||||
<br>
|
||||
|
||||
@ -37,10 +27,11 @@ NarratoAI 是一款自动化影视解说工具,基于 LLM 实现文案撰写
|
||||

|
||||
|
||||
</div>
|
||||
## 许可证
|
||||
本项目仅供学习和研究使用,不得商用。如需商业授权,请联系作者。
|
||||
|
||||
## 最新资讯
|
||||
- 2026.07.16 发布新版本 0.8.6,新增可选的 **Sonilo AI 配乐**,支持 Apple Silicon 的 **IndexTTS-1.5 MLX**,更新 IndexTTS-2 MLX API 适配,并补充 OmniVoice 部署包下载入口
|
||||
- 2026.07.13 发布新版本 0.8.5,完善 `uv` + Python 3.12 本地运行流程,新增一键清理系统缓存和自定义 OpenAI 兼容 Base URL 的格式校验与 API Key 风险提示,并优化短脚本解析、字幕预览和音频设置的跨平台兼容性
|
||||
- 2026.07.02 发布新版本 0.8.4,升级豆包语音 TTS 新版 API Key 配置并保留旧版凭据兼容
|
||||
- 2026.06.10 发布新版本 0.8.1,**大版本更新**,优化多个核心流程
|
||||
- 2026.04.27 发布新版本 0.7.9,新增 **Fun-ASR一键转录字幕**
|
||||
- 2026.04.03 发布新版本 0.7.8,重构纪录片逐帧分析链路,统一共享服务并优化抽帧、缓存、视觉并发与文案生成流程
|
||||
@ -57,6 +48,17 @@ NarratoAI 是一款自动化影视解说工具,基于 LLM 实现文案撰写
|
||||
- 2024.11.10 发布官方文档,详情参见 [官方文档](https://p9mf6rjv3c.feishu.cn/wiki/SP8swLLZki5WRWkhuFvc2CyInDg)
|
||||
- 2024.11.10 发布新版本 v0.3.5;优化视频剪辑流程,
|
||||
|
||||
### v0.8.6 更新亮点
|
||||
|
||||
| 功能 | 说明 |
|
||||
| --- | --- |
|
||||
| Sonilo AI 配乐 | 在 WebUI 的背景音乐来源中选择“AI 生成配乐(Sonilo)”,即可根据画面内容和剪辑节奏生成配乐。该功能默认关闭,需自行配置 [Sonilo](https://sonilo.com) API Key;当前支持最长 6 分钟的视频,生成失败时会自动回退到随机背景音乐,不中断成片任务。 |
|
||||
| IndexTTS-1.5 macOS | 新增面向 Apple Silicon 的 MLX 本地语音克隆引擎,可上传或从资源目录选择参考音频。[下载部署包](https://cutagent.online/resources/indextts15-mlx-macos) |
|
||||
| IndexTTS-2 MLX | 更新 MLX Pack API 适配,支持参考音频、情感控制、随机种子和更完整的生成参数。[macOS 部署包](https://cutagent.online/resources/indextts2-full-macos)(Windows 版本待更新) |
|
||||
| OmniVoice | 补充 OmniVoice-Pack 部署包下载入口;该引擎支持自动音色、指令音色和参考音频克隆。[macOS 部署包](https://cutagent.online/resources/omnivoice-macos) / [Windows 部署包](https://cutagent.online/resources/omnivoice-windows) |
|
||||
|
||||
> 使用 Sonilo AI 配乐时,合成完成且尚未添加背景音乐的视频会上传至 Sonilo API。请在启用前确认视频内容符合相关服务条款与隐私要求;生成音乐的授权和商用范围以 Sonilo 最新条款为准。
|
||||
|
||||
## 重磅福利 🎉
|
||||
|
||||
> 即日起全面支持硅基流动!注册即享2000万免费Token(价值16元平台配额),剪辑10分钟视频仅需0.1元!
|
||||
@ -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 改名后售卖,下面是部分截图,请大家务必提高警惕,切勿上当受骗**_
|
||||
|
||||
---
|
||||
@ -93,6 +97,7 @@ _**1. NarratoAI 是一款完全免费的软件,近期在社交媒体(抖音,B
|
||||
- [x] 优化剧情生成流程,提升生成效果
|
||||
- [x] 发布 0.3.5 整合包
|
||||
- [x] 支持阿里 Qwen2-VL 大模型理解视频
|
||||
- [x] 支持 TwelveLabs Pegasus 作为可选的视频理解后端(原生理解整段画面以挑选高光、生成解说;可选启用,设置 `vision_llm_provider = "twelvelabs"`)
|
||||
- [x] 支持短剧混剪
|
||||
- [x] 一键合并素材
|
||||
- [x] 一键转录
|
||||
@ -102,11 +107,38 @@ _**1. NarratoAI 是一款完全免费的软件,近期在社交媒体(抖音,B
|
||||
- [ ] 主角人脸匹配
|
||||
- [ ] 支持根据口播,文案,视频素材自动匹配
|
||||
- [X] 支持更多 TTS 引擎
|
||||
- [X] 支持可选的 AI 自动配乐
|
||||
- [ ] ...
|
||||
|
||||
## 快速启动 🚀
|
||||
|
||||
### 方式一:macos Docker 部署(macos 推荐)
|
||||
### 方式一:整合包(推荐)
|
||||
> 下载地址:[https://cutagent.online/](https://cutagent.online/)
|
||||
|
||||
请下载与系统对应的整合包并完整解压;不要单独移动其中的 `NarratoAI`、`runtime` 或 `tools` 等目录。Windows 使用 x64 版本;macOS 使用 Apple Silicon(M1/M2/M3/M4)arm64 版本。
|
||||
|
||||
#### Windows
|
||||
|
||||
1. 打开解压后的 `NarratoAI-windows-x64` 目录。
|
||||
2. 先双击 `update-windows.bat` 更新项目,等待窗口提示更新完成。
|
||||
3. 再双击 `start.bat` 启动应用,并保持启动窗口打开。
|
||||
4. 在浏览器访问 `http://127.0.0.1:8501`。
|
||||
|
||||
#### macOS(Apple 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
|
||||
# 1. 克隆项目
|
||||
git clone https://github.com/linyqh/NarratoAI.git
|
||||
@ -118,17 +150,14 @@ docker compose up -d
|
||||
# 3. 访问应用
|
||||
# 浏览器打开 http://localhost:8501
|
||||
```
|
||||
### 方式二:整合包(Windows 推荐)
|
||||
> *关注微信公众号 **NarratoAI 助手** 右下角菜单栏获取下载链接*
|
||||
|
||||
### 方式三:本地运行
|
||||
```bash
|
||||
# 1. 克隆项目
|
||||
git clone https://github.com/linyqh/NarratoAI.git
|
||||
cd NarratoAI
|
||||
|
||||
# 2. 安装依赖
|
||||
pip install -r requirements.txt
|
||||
# 2. 使用 uv 安装依赖
|
||||
uv sync
|
||||
|
||||
# 3. 复制配置文件
|
||||
cp config.example.toml config.toml
|
||||
@ -136,7 +165,7 @@ cp config.example.toml config.toml
|
||||
# 4. 编辑 config.toml,配置你的 API 密钥
|
||||
|
||||
# 5. 启动应用
|
||||
streamlit run webui.py --server.maxUploadSize=2048
|
||||
uv run streamlit run webui.py --server.maxUploadSize=2048
|
||||
|
||||
# 6. 访问应用
|
||||
# 浏览器打开 http://localhost:8501
|
||||
|
||||
@ -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"
|
||||
version_file = f"{root_dir}/project_version"
|
||||
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_DISPLAY_NAME = "IndexTTS-2"
|
||||
OMNIVOICE_ENGINE = "omnivoice"
|
||||
OMNIVOICE_DISPLAY_NAME = "OmniVoice"
|
||||
VOXCPM_ENGINE = "voxcpm_05b"
|
||||
VOXCPM_DISPLAY_NAME = "VoxCPM-0.5B"
|
||||
VOXCPM2_ENGINE = "voxcpm_2b"
|
||||
VOXCPM2_DISPLAY_NAME = "VoxCPM-2B"
|
||||
INDEXTTS_VOICE_PREFIX = f"{INDEXTTS_ENGINE}:"
|
||||
INDEXTTS_MACOS_VOICE_PREFIX = f"{INDEXTTS_MACOS_ENGINE}:"
|
||||
INDEXTTS2_VOICE_PREFIX = f"{INDEXTTS2_ENGINE}:"
|
||||
OMNIVOICE_VOICE_PREFIX = f"{OMNIVOICE_ENGINE}:"
|
||||
VOXCPM_VOICE_PREFIX = f"{VOXCPM_ENGINE}:"
|
||||
VOXCPM2_VOICE_PREFIX = f"{VOXCPM2_ENGINE}:"
|
||||
INDEXTTS2_EMOTION_VECTOR_FIELDS = (
|
||||
("happy", "vec_happy"),
|
||||
("angry", "vec_angry"),
|
||||
("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:
|
||||
@ -28,6 +47,32 @@ def normalize_indextts_voice_prefix(voice_name: str) -> str:
|
||||
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:
|
||||
if not isinstance(indextts2_config, dict):
|
||||
return False
|
||||
@ -133,8 +178,11 @@ def save_config():
|
||||
_cfg["tts_qwen"] = tts_qwen
|
||||
_cfg["fun_asr"] = fun_asr
|
||||
_cfg["indextts"] = indextts
|
||||
_cfg["indextts_macos"] = indextts_macos
|
||||
_cfg["indextts2"] = indextts2
|
||||
_cfg["omnivoice"] = omnivoice
|
||||
_cfg["voxcpm_05b"] = voxcpm_05b
|
||||
_cfg["voxcpm_2b"] = voxcpm_2b
|
||||
_cfg["doubaotts"] = doubaotts
|
||||
f.write(toml.dumps(_cfg))
|
||||
|
||||
@ -151,8 +199,11 @@ frames = _cfg.get("frames", {})
|
||||
tts_qwen = _cfg.get("tts_qwen", {})
|
||||
fun_asr = _cfg.get("fun_asr", {})
|
||||
indextts = _cfg.get("indextts", {})
|
||||
indextts_macos = _cfg.get("indextts_macos", {})
|
||||
indextts2 = _cfg.get("indextts2", {})
|
||||
omnivoice = _cfg.get("omnivoice", {})
|
||||
voxcpm_05b = _cfg.get("voxcpm_05b", {})
|
||||
voxcpm_2b = _cfg.get("voxcpm_2b", {})
|
||||
doubaotts = _cfg.get("doubaotts", {})
|
||||
|
||||
hostname = socket.gethostname()
|
||||
|
||||
@ -10,6 +10,7 @@ DEFAULT_VISION_OPENAI_MODEL_NAME = "Qwen/Qwen3.5-122B-A10B"
|
||||
|
||||
DEFAULT_TEXT_LLM_PROVIDER = DEFAULT_OPENAI_COMPATIBLE_PROVIDER
|
||||
DEFAULT_TEXT_OPENAI_MODEL_NAME = "Pro/zai-org/GLM-5"
|
||||
DEFAULT_TEXT_OPENAI_FAST_MODEL_NAME = ""
|
||||
|
||||
DEFAULT_LLM_GENERATION_CONFIG = {
|
||||
"temperature": 1.0,
|
||||
@ -33,6 +34,7 @@ DEFAULT_LLM_APP_CONFIG = {
|
||||
"vision_openai_base_url": DEFAULT_OPENAI_COMPATIBLE_BASE_URL,
|
||||
"text_llm_provider": DEFAULT_TEXT_LLM_PROVIDER,
|
||||
"text_openai_model_name": DEFAULT_TEXT_OPENAI_MODEL_NAME,
|
||||
"text_openai_fast_model_name": DEFAULT_TEXT_OPENAI_FAST_MODEL_NAME,
|
||||
"text_openai_api_key": "",
|
||||
"text_openai_base_url": DEFAULT_OPENAI_COMPATIBLE_BASE_URL,
|
||||
"tavily_api_key": "",
|
||||
@ -69,6 +71,31 @@ def normalize_openai_compatible_model_name(
|
||||
return normalized
|
||||
|
||||
|
||||
def resolve_text_model_name(
|
||||
app_config: dict,
|
||||
provider: str = DEFAULT_OPENAI_COMPATIBLE_PROVIDER,
|
||||
*,
|
||||
prefer_fast: bool = False,
|
||||
) -> str:
|
||||
"""Resolve the configured reasoning or fast text model with legacy fallback."""
|
||||
provider = (provider or DEFAULT_OPENAI_COMPATIBLE_PROVIDER).strip().lower()
|
||||
reasoning_model = normalize_openai_compatible_model_name(
|
||||
str(app_config.get(f"text_{provider}_model_name") or ""),
|
||||
provider=provider,
|
||||
)
|
||||
if not reasoning_model and provider == DEFAULT_OPENAI_COMPATIBLE_PROVIDER:
|
||||
reasoning_model = DEFAULT_TEXT_OPENAI_MODEL_NAME
|
||||
|
||||
if not prefer_fast:
|
||||
return reasoning_model
|
||||
|
||||
fast_model = normalize_openai_compatible_model_name(
|
||||
str(app_config.get(f"text_{provider}_fast_model_name") or ""),
|
||||
provider=provider,
|
||||
)
|
||||
return fast_model or reasoning_model
|
||||
|
||||
|
||||
def get_openai_compatible_ui_values(
|
||||
full_model_name: str,
|
||||
default_model: str,
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
try:
|
||||
import tomllib
|
||||
@ -11,10 +12,35 @@ from app.config import config as cfg
|
||||
from app.config.defaults import (
|
||||
get_openai_compatible_ui_values,
|
||||
normalize_openai_compatible_model_name,
|
||||
resolve_text_model_name,
|
||||
)
|
||||
|
||||
|
||||
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):
|
||||
original_root_dir = cfg.root_dir
|
||||
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("openai", config_data["app"]["text_llm_provider"])
|
||||
self.assertEqual("Pro/zai-org/GLM-5", config_data["app"]["text_openai_model_name"])
|
||||
self.assertEqual("", config_data["app"]["text_openai_fast_model_name"])
|
||||
self.assertEqual("https://api.siliconflow.cn/v1", config_data["app"]["text_openai_base_url"])
|
||||
self.assertEqual(1.0, config_data["app"]["text_openai_temperature"])
|
||||
self.assertEqual(0.95, config_data["app"]["text_openai_top_p"])
|
||||
self.assertEqual("Qwen/Qwen3.5-122B-A10B", saved_config["app"]["vision_openai_model_name"])
|
||||
self.assertEqual("Pro/zai-org/GLM-5", saved_config["app"]["text_openai_model_name"])
|
||||
self.assertEqual("", saved_config["app"]["text_openai_fast_model_name"])
|
||||
self.assertTrue(saved_config["app"]["hide_config"])
|
||||
|
||||
def test_legacy_indextts2_config_is_migrated_to_indextts_15(self):
|
||||
@ -102,6 +130,23 @@ hide_config = true
|
||||
|
||||
|
||||
class OpenAICompatibleModelDefaultsTests(unittest.TestCase):
|
||||
def test_fast_text_model_falls_back_to_reasoning_model(self):
|
||||
app_config = {
|
||||
"text_openai_model_name": "reasoning-model",
|
||||
"text_openai_fast_model_name": "",
|
||||
}
|
||||
|
||||
self.assertEqual(
|
||||
"reasoning-model",
|
||||
resolve_text_model_name(app_config, "openai", prefer_fast=True),
|
||||
)
|
||||
|
||||
app_config["text_openai_fast_model_name"] = "fast-model"
|
||||
self.assertEqual(
|
||||
"fast-model",
|
||||
resolve_text_model_name(app_config, "openai", prefer_fast=True),
|
||||
)
|
||||
|
||||
def test_ui_keeps_full_model_name_and_openai_provider(self):
|
||||
provider, model_name = get_openai_compatible_ui_values(
|
||||
"Qwen/Qwen3.5-122B-A10B",
|
||||
|
||||
@ -183,6 +183,7 @@ class VideoClipParams(BaseModel):
|
||||
bgm_name: Optional[str] = Field(default="random", description="背景音乐名称")
|
||||
bgm_type: Optional[str] = Field(default="random", description="背景音乐类型")
|
||||
bgm_file: Optional[str] = Field(default="", description="背景音乐文件")
|
||||
sonilo_sfx_enabled: Optional[bool] = Field(default=False, description="是否启用 Sonilo AI 音效(可选功能,默认关闭)")
|
||||
|
||||
subtitle_enabled: bool = True
|
||||
subtitle_mask_enabled: bool = False
|
||||
|
||||
@ -409,6 +409,7 @@ class SubtitleAnalyzer:
|
||||
temperature: float = 0.7,
|
||||
narration_language: str = "简体中文(中国)",
|
||||
drama_genre: str = "逆袭/复仇",
|
||||
narration_word_count: int = 500,
|
||||
) -> Dict[str, Any]:
|
||||
"""生成供用户审核修改的解说正文。"""
|
||||
try:
|
||||
@ -420,6 +421,7 @@ class SubtitleAnalyzer:
|
||||
"plot_analysis": plot_analysis,
|
||||
"subtitle_content": subtitle_content,
|
||||
"narration_language": narration_language,
|
||||
"narration_word_count": int(narration_word_count),
|
||||
},
|
||||
)
|
||||
return self._generate_plain_text(prompt, system_prompt, temperature)
|
||||
@ -964,6 +966,7 @@ def generate_narration_copy(
|
||||
narration_language: str = "简体中文(中国)",
|
||||
drama_genre: str = "逆袭/复仇",
|
||||
prompt_category: str = "short_drama_narration",
|
||||
narration_word_count: int = 500,
|
||||
) -> Dict[str, Any]:
|
||||
"""生成可供用户审核修改的解说正文。"""
|
||||
analyzer = SubtitleAnalyzer(
|
||||
@ -982,6 +985,7 @@ def generate_narration_copy(
|
||||
temperature=temperature,
|
||||
narration_language=narration_language,
|
||||
drama_genre=drama_genre,
|
||||
narration_word_count=narration_word_count,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
"""
|
||||
视频脚本生成pipeline,串联各个处理步骤
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
from typing import Any, Dict, Optional
|
||||
from loguru import logger
|
||||
|
||||
@ -16,6 +18,10 @@ def generate_script_result(
|
||||
base_url: str = None,
|
||||
custom_clips: int = 5,
|
||||
provider: str = None,
|
||||
video_paths=None,
|
||||
plot_analysis: Optional[str] = None,
|
||||
short_name: str = "",
|
||||
drama_genre: str = "",
|
||||
*,
|
||||
srt_path: Optional[str] = None,
|
||||
subtitle_content: Optional[str] = None,
|
||||
@ -30,6 +36,10 @@ def generate_script_result(
|
||||
base_url: API基础URL,可选
|
||||
custom_clips: 自定义片段数量,默认5
|
||||
provider: LLM服务提供商,可选
|
||||
video_paths: 原始视频路径列表,用于生成 video_id/video_name
|
||||
plot_analysis: 已完成的剧情理解文本,提供时会跳过混剪内部剧情理解
|
||||
short_name: 短剧名称
|
||||
drama_genre: 短剧类型
|
||||
srt_path: 字幕文件路径(向后兼容)
|
||||
subtitle_content: 字幕文本内容
|
||||
subtitle_file_path: 字幕文件路径(推荐)
|
||||
@ -56,10 +66,23 @@ def generate_script_result(
|
||||
provider=provider,
|
||||
srt_path=resolved_path,
|
||||
subtitle_content=resolved_content,
|
||||
plot_analysis=plot_analysis,
|
||||
video_paths=video_paths,
|
||||
short_name=short_name,
|
||||
drama_genre=drama_genre,
|
||||
)
|
||||
|
||||
adjusted_results = openai_analysis['plot_points']
|
||||
final_script = merge_script(adjusted_results, output_path)
|
||||
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']
|
||||
final_script = merge_script(adjusted_results, output_path, video_paths=video_paths)
|
||||
|
||||
return {"status": "success", "script": final_script}
|
||||
|
||||
@ -79,6 +102,10 @@ def generate_script(
|
||||
base_url: str = None,
|
||||
custom_clips: int = 5,
|
||||
provider: str = None,
|
||||
video_paths=None,
|
||||
plot_analysis: Optional[str] = None,
|
||||
short_name: str = "",
|
||||
drama_genre: str = "",
|
||||
*,
|
||||
subtitle_content: Optional[str] = None,
|
||||
subtitle_file_path: Optional[str] = None,
|
||||
@ -93,6 +120,10 @@ def generate_script(
|
||||
base_url: API基础URL,可选
|
||||
custom_clips: 自定义片段数量,默认5
|
||||
provider: LLM服务提供商,可选
|
||||
video_paths: 原始视频路径列表,用于生成 video_id/video_name
|
||||
plot_analysis: 已完成的剧情理解文本
|
||||
short_name: 短剧名称
|
||||
drama_genre: 短剧类型
|
||||
subtitle_content: 字幕文本内容(可选)
|
||||
subtitle_file_path: 字幕文件路径(推荐使用,可选)
|
||||
|
||||
@ -110,6 +141,10 @@ def generate_script(
|
||||
base_url=base_url,
|
||||
custom_clips=custom_clips,
|
||||
provider=provider,
|
||||
video_paths=video_paths,
|
||||
plot_analysis=plot_analysis,
|
||||
short_name=short_name,
|
||||
drama_genre=drama_genre,
|
||||
srt_path=srt_path,
|
||||
subtitle_content=subtitle_content,
|
||||
subtitle_file_path=subtitle_file_path,
|
||||
|
||||
@ -1,11 +1,16 @@
|
||||
"""
|
||||
使用统一LLM服务,分析字幕文件,返回剧情梗概和爆点
|
||||
"""
|
||||
import os
|
||||
import traceback
|
||||
import json
|
||||
from loguru import logger
|
||||
|
||||
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
|
||||
# 导入统一LLM服务
|
||||
@ -14,6 +19,176 @@ from app.services.llm.unified_service import UnifiedLLMService
|
||||
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(
|
||||
model_name: str,
|
||||
api_key: str = None,
|
||||
@ -21,7 +196,11 @@ def analyze_subtitle(
|
||||
custom_clips: int = 5,
|
||||
provider: 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:
|
||||
"""分析字幕内容,返回完整的分析结果
|
||||
|
||||
@ -33,6 +212,10 @@ def analyze_subtitle(
|
||||
provider (str, optional): LLM服务提供商. Defaults to None.
|
||||
srt_path (str, optional): SRT字幕文件路径(与subtitle_content二选一)
|
||||
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:
|
||||
dict: 包含剧情梗概和结构化的时间段分析的字典
|
||||
@ -87,6 +270,27 @@ def analyze_subtitle(
|
||||
|
||||
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(
|
||||
category="short_drama_editing",
|
||||
@ -120,6 +324,28 @@ def analyze_subtitle(
|
||||
logger.info(f"字幕分析完成,找到 {len(summary_data.get('plot_titles', []))} 个关键情节")
|
||||
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 = ""
|
||||
logger.info(f"找到 {len(summary_data.get('plot_titles', []))} 个片段")
|
||||
|
||||
@ -8,7 +8,8 @@ from typing import Dict, List
|
||||
|
||||
def merge_script(
|
||||
plot_points: List[Dict],
|
||||
output_path: str
|
||||
output_path: str,
|
||||
video_paths=None,
|
||||
):
|
||||
"""合并生成最终脚本
|
||||
|
||||
@ -19,6 +20,10 @@ def merge_script(
|
||||
Returns:
|
||||
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 = []
|
||||
|
||||
@ -29,9 +34,12 @@ def merge_script(
|
||||
"_id": number,
|
||||
"timestamp": plot_point["timestamp"],
|
||||
"picture": plot_point["picture"],
|
||||
"narration": f"播放原生_{os.urandom(4).hex()}",
|
||||
"narration": f"播放原片{number}",
|
||||
"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)
|
||||
number += 1
|
||||
|
||||
|
||||
@ -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"
|
||||
TASK_URL_TEMPLATE = f"{DASHSCOPE_BASE_URL}/api/v1/tasks/{{task_id}}"
|
||||
MODEL_NAME = "fun-asr"
|
||||
LOCAL_FUN_ASR_OPENAI_MODEL = "sensevoice"
|
||||
LOCAL_FUN_ASR_API_URL = "http://127.0.0.1:7860"
|
||||
LOCAL_FIRERED_ASR_API_URL = "http://127.0.0.1:7867"
|
||||
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)
|
||||
parsed = urlparse(api_url)
|
||||
path = parsed.path.rstrip("/")
|
||||
if path.endswith("/asr"):
|
||||
path = path[:-4].rstrip("/")
|
||||
for suffix in ("/v1/audio/transcriptions", "/v1", "/asr"):
|
||||
if path.endswith(suffix):
|
||||
path = path[: -len(suffix)].rstrip("/")
|
||||
break
|
||||
return urlunparse(parsed._replace(path=path, params="", query="", fragment="")).rstrip("/")
|
||||
|
||||
|
||||
def _local_asr_url(api_url: str = "") -> str:
|
||||
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
|
||||
if path.endswith("/v1") or path.endswith("/v1/audio/transcriptions"):
|
||||
return f"{_local_base_url(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:
|
||||
download_url = (download_url or "").strip()
|
||||
if not download_url:
|
||||
@ -547,27 +572,52 @@ def request_local_fun_asr(
|
||||
api_url: str = LOCAL_FUN_ASR_API_URL,
|
||||
hotword: str = "",
|
||||
enable_spk: Optional[bool] = None,
|
||||
model: str = LOCAL_FUN_ASR_OPENAI_MODEL,
|
||||
timeout: float = 600.0,
|
||||
session=requests,
|
||||
) -> 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)
|
||||
data: dict[str, str] = {}
|
||||
rest_data: dict[str, str] = {}
|
||||
if hotword.strip():
|
||||
data["hotword"] = hotword.strip()
|
||||
rest_data["hotword"] = hotword.strip()
|
||||
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"
|
||||
|
||||
with open(local_file, "rb") as file_obj:
|
||||
files = {"file": (_safe_upload_name(local_file), file_obj)}
|
||||
response = _session_post(
|
||||
session,
|
||||
_local_asr_url(api_url),
|
||||
data=data,
|
||||
files=files,
|
||||
timeout=timeout,
|
||||
)
|
||||
return _local_json(response, "调用本地 FunASR-Pack ASR API")
|
||||
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:
|
||||
files = {"file": (_safe_upload_name(local_file), file_obj)}
|
||||
response = _session_post(
|
||||
session,
|
||||
url,
|
||||
data=data,
|
||||
files=files,
|
||||
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(last_response, "调用本地 FunASR-Pack ASR API")
|
||||
|
||||
|
||||
def request_local_firered_asr(
|
||||
@ -640,6 +690,40 @@ def _local_result_items(result_json: dict[str, Any]):
|
||||
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]]:
|
||||
text = str(item.get("text") or "").strip()
|
||||
timestamps = item.get("timestamp") or []
|
||||
@ -702,7 +786,7 @@ def local_fun_asr_result_to_srt(
|
||||
max_duration: float = 3.5,
|
||||
) -> str:
|
||||
"""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):
|
||||
item_blocks = _blocks_from_local_timestamp(item, max_chars, max_duration)
|
||||
if not item_blocks:
|
||||
|
||||
@ -114,6 +114,10 @@ def _normalize_indextts_reference_audio(params: VideoClipParams) -> None:
|
||||
tts_config = config.indextts
|
||||
voice_prefix = config.INDEXTTS_VOICE_PREFIX
|
||||
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:
|
||||
tts_config = config.indextts2
|
||||
voice_prefix = config.INDEXTTS2_VOICE_PREFIX
|
||||
|
||||
@ -17,6 +17,10 @@ import subprocess
|
||||
from typing import Union, TextIO
|
||||
|
||||
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
|
||||
|
||||
_max_retries = 5
|
||||
@ -330,6 +334,10 @@ def _generate_response(prompt: str, llm_provider: str = None) -> str:
|
||||
azure_endpoint=base_url,
|
||||
)
|
||||
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(
|
||||
api_key=api_key,
|
||||
base_url=base_url,
|
||||
|
||||
@ -164,11 +164,13 @@ class LLMConfigValidator:
|
||||
config_prefix = f"text_{provider_name}"
|
||||
api_key = config.app.get(f'{config_prefix}_api_key')
|
||||
model_name = config.app.get(f'{config_prefix}_model_name')
|
||||
fast_model_name = config.app.get(f'{config_prefix}_fast_model_name')
|
||||
base_url = config.app.get(f'{config_prefix}_base_url')
|
||||
|
||||
result["config"] = {
|
||||
"api_key": "***" if api_key else None,
|
||||
"model_name": model_name,
|
||||
"fast_model_name": fast_model_name,
|
||||
"base_url": base_url
|
||||
}
|
||||
|
||||
@ -241,7 +243,8 @@ class LLMConfigValidator:
|
||||
f"text_{provider}_model_name"
|
||||
],
|
||||
"optional_configs": [
|
||||
f"text_{provider}_base_url"
|
||||
f"text_{provider}_base_url",
|
||||
f"text_{provider}_fast_model_name",
|
||||
],
|
||||
"example_models": LLMConfigValidator._get_example_models(provider, "text")
|
||||
}
|
||||
|
||||
@ -292,6 +292,7 @@ class SubtitleAnalyzerAdapter:
|
||||
temperature: float = 0.7,
|
||||
narration_language: str = "简体中文(中国)",
|
||||
drama_genre: str = "逆袭/复仇",
|
||||
narration_word_count: int = 500,
|
||||
) -> Dict[str, Any]:
|
||||
"""Generate editable narration copy before timeline matching."""
|
||||
try:
|
||||
@ -303,6 +304,7 @@ class SubtitleAnalyzerAdapter:
|
||||
"plot_analysis": plot_analysis,
|
||||
"subtitle_content": subtitle_content,
|
||||
"narration_language": narration_language,
|
||||
"narration_word_count": int(narration_word_count),
|
||||
},
|
||||
)
|
||||
narration_copy = self._generate_plain_text(prompt, system_prompt, temperature)
|
||||
|
||||
@ -23,8 +23,13 @@ from openai import (
|
||||
|
||||
from app.config import config
|
||||
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 .exceptions import APICallError, AuthenticationError, ContentFilterError, RateLimitError
|
||||
from .exceptions import APICallError, AuthenticationError, ConfigurationError, ContentFilterError, RateLimitError
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
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:
|
||||
"""清理 JSON 输出中的 markdown 包裹。"""
|
||||
output = re.sub(r"^```json\s*", "", output, flags=re.MULTILINE)
|
||||
@ -118,6 +134,7 @@ class _OpenAICompatibleBase:
|
||||
"""按请求构建 AsyncOpenAI 客户端,支持动态覆盖 api_key / base_url。"""
|
||||
api_key = api_key_override or self.api_key
|
||||
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)
|
||||
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],
|
||||
kwargs: Dict[str, Any],
|
||||
) -> Dict[str, Any]:
|
||||
model_name = _normalize_model_name(self.model_name)
|
||||
generation_kwargs = dict(kwargs)
|
||||
model_override = generation_kwargs.pop("model", None) or generation_kwargs.pop("model_name", None)
|
||||
model_name = _normalize_model_name(model_override or self.model_name)
|
||||
temperature_override = generation_kwargs.pop("temperature", None)
|
||||
if temperature_override is None and temperature != 1.0:
|
||||
temperature_override = temperature
|
||||
|
||||
@ -12,7 +12,7 @@ def register_all_providers():
|
||||
"""
|
||||
注册所有提供商
|
||||
|
||||
当前实现:只注册 OpenAI 兼容统一接口
|
||||
当前实现:注册 OpenAI 兼容统一接口,并可选注册 TwelveLabs Pegasus 视频理解。
|
||||
"""
|
||||
# 在函数内部导入,避免循环依赖
|
||||
from ..manager import LLMServiceManager
|
||||
@ -32,6 +32,14 @@ def register_all_providers():
|
||||
|
||||
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__ = [
|
||||
|
||||
@ -6,10 +6,17 @@ from unittest.mock import patch
|
||||
|
||||
from app.config import config
|
||||
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.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.utils.openai_base_url_security import openai_compatible_base_url_warning
|
||||
|
||||
|
||||
class DummyOpenAITextProvider(TextModelProvider):
|
||||
@ -45,11 +52,12 @@ class OpenAICompatManagerTests(unittest.TestCase):
|
||||
config.app.clear()
|
||||
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()
|
||||
|
||||
# 文本仅 OpenAI 兼容;视觉额外提供可选的 TwelveLabs Pegasus。
|
||||
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):
|
||||
LLMServiceManager.register_text_provider("openai", DummyOpenAITextProvider)
|
||||
@ -141,6 +149,20 @@ class OpenAICompatGenerationOptionTests(unittest.TestCase):
|
||||
self.assertEqual(65536, options["max_tokens"])
|
||||
self.assertNotIn("extra_body", options)
|
||||
|
||||
def test_text_request_can_override_model_for_fast_tasks(self):
|
||||
provider = OpenAICompatibleTextProvider(api_key="k", model_name="reasoning-model")
|
||||
|
||||
options = provider._build_text_completion_kwargs(
|
||||
messages=[{"role": "user", "content": "hello"}],
|
||||
temperature=0.2,
|
||||
max_tokens=None,
|
||||
response_format=None,
|
||||
kwargs={"model": "fast-model", "thinking_level": "off"},
|
||||
)
|
||||
|
||||
self.assertEqual("fast-model", options["model"])
|
||||
self.assertNotIn("extra_body", options)
|
||||
|
||||
def test_build_options_uses_per_model_generation_config(self):
|
||||
provider = OpenAICompatibleTextProvider(api_key="k", model_name="m")
|
||||
config.app.update(
|
||||
@ -169,6 +191,77 @@ class OpenAICompatGenerationOptionTests(unittest.TestCase):
|
||||
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 _CapturingVisionProvider:
|
||||
last_init: tuple[str, str, str | None] | None = None
|
||||
|
||||
@ -24,11 +24,14 @@ class SubtitleAnalyzerAdapterPipelineTests(unittest.TestCase):
|
||||
temperature=0.7,
|
||||
narration_language="简体中文(中国)",
|
||||
drama_genre="家庭伦理",
|
||||
narration_word_count=800,
|
||||
)
|
||||
|
||||
self.assertEqual("success", result["status"])
|
||||
self.assertIn("反击", result["narration_copy"])
|
||||
self.assertIn("家庭伦理", call.call_args.kwargs["prompt"])
|
||||
self.assertIn("800", call.call_args.kwargs["prompt"])
|
||||
self.assertNotIn("300-650", call.call_args.kwargs["prompt"])
|
||||
self.assertNotIn("response_format", call.call_args.kwargs)
|
||||
|
||||
def test_generate_narration_copy_can_use_film_tv_prompt_category(self):
|
||||
@ -49,11 +52,14 @@ class SubtitleAnalyzerAdapterPipelineTests(unittest.TestCase):
|
||||
temperature=0.7,
|
||||
narration_language="简体中文(中国)",
|
||||
drama_genre="悬疑/犯罪",
|
||||
narration_word_count=1200,
|
||||
)
|
||||
|
||||
self.assertEqual("success", result["status"])
|
||||
self.assertIn("影视解说正文创作任务", call.call_args.kwargs["prompt"])
|
||||
self.assertIn("用户选择的影视类型", call.call_args.kwargs["prompt"])
|
||||
self.assertIn("1200", call.call_args.kwargs["prompt"])
|
||||
self.assertNotIn("350-750", call.call_args.kwargs["prompt"])
|
||||
self.assertNotIn("短剧解说正文创作任务", call.call_args.kwargs["prompt"])
|
||||
|
||||
def test_film_tv_script_prompts_exclude_intro_outro_and_ads(self):
|
||||
|
||||
91
app/services/llm/test_twelvelabs_provider_unittest.py
Normal 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()
|
||||
253
app/services/llm/twelvelabs_provider.py
Normal 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
|
||||
@ -22,7 +22,14 @@ class NarrationCopyPrompt(ParameterizedPrompt):
|
||||
model_type=ModelType.TEXT,
|
||||
output_format=OutputFormat.TEXT,
|
||||
tags=["影视", "解说文案", "电影解说", "剧情承接", "用户审核"],
|
||||
parameters=["drama_name", "drama_genre", "plot_analysis", "subtitle_content", "narration_language"],
|
||||
parameters=[
|
||||
"drama_name",
|
||||
"drama_genre",
|
||||
"plot_analysis",
|
||||
"subtitle_content",
|
||||
"narration_language",
|
||||
"narration_word_count",
|
||||
],
|
||||
)
|
||||
super().__init__(metadata, required_parameters=["drama_name", "plot_analysis", "subtitle_content"])
|
||||
|
||||
@ -57,6 +64,11 @@ ${narration_language}
|
||||
${drama_genre}
|
||||
</drama_genre>
|
||||
|
||||
## 用户要求的文案字数
|
||||
<narration_word_count>
|
||||
${narration_word_count}
|
||||
</narration_word_count>
|
||||
|
||||
## 类型写作规则
|
||||
必须按用户选择的影视类型调整表达重点,不要自行改判类型:
|
||||
- 剧情/情感:突出人物选择、关系裂痕、命运压力和情绪余波。
|
||||
@ -81,7 +93,7 @@ ${drama_genre}
|
||||
4. 每句话只表达一个信息点,适合后续按句匹配画面。
|
||||
5. 句子尽量短,单句优先 15-35 字;信息复杂时拆成多句。
|
||||
6. 每 2-3 句要有明确承接,让观众知道为什么从上一幕来到下一幕。
|
||||
7. 总长度控制在 350-750 字;短素材取下限,长素材取上限。
|
||||
7. 总长度以 ${narration_word_count} 字为目标,允许上下浮动 10%;中日韩语言按非空白字符计数,其他语言按单词计数。不得再套用固定长度区间。
|
||||
8. 不要使用编号、项目符号、章节标题或括号说明。
|
||||
|
||||
## 输出要求
|
||||
|
||||
@ -11,6 +11,7 @@
|
||||
|
||||
from .subtitle_analysis import SubtitleAnalysisPrompt
|
||||
from .plot_extraction import PlotExtractionPrompt
|
||||
from .script_generation import ScriptGenerationPrompt
|
||||
from ..manager import PromptManager
|
||||
|
||||
|
||||
@ -25,9 +26,14 @@ def register_prompts():
|
||||
plot_extraction_prompt = PlotExtractionPrompt()
|
||||
PromptManager.register_prompt(plot_extraction_prompt, is_default=True)
|
||||
|
||||
# 注册混剪脚本生成提示词
|
||||
script_generation_prompt = ScriptGenerationPrompt()
|
||||
PromptManager.register_prompt(script_generation_prompt, is_default=True)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"SubtitleAnalysisPrompt",
|
||||
"PlotExtractionPrompt",
|
||||
"PlotExtractionPrompt",
|
||||
"ScriptGenerationPrompt",
|
||||
"register_prompts"
|
||||
]
|
||||
|
||||
113
app/services/prompts/short_drama_editing/script_generation.py
Normal 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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
现在请生成短剧混剪脚本。"""
|
||||
@ -22,7 +22,14 @@ class NarrationCopyPrompt(ParameterizedPrompt):
|
||||
model_type=ModelType.TEXT,
|
||||
output_format=OutputFormat.TEXT,
|
||||
tags=["短剧", "解说文案", "爆款开头", "叙事连续性", "用户审核"],
|
||||
parameters=["drama_name", "drama_genre", "plot_analysis", "subtitle_content", "narration_language"],
|
||||
parameters=[
|
||||
"drama_name",
|
||||
"drama_genre",
|
||||
"plot_analysis",
|
||||
"subtitle_content",
|
||||
"narration_language",
|
||||
"narration_word_count",
|
||||
],
|
||||
)
|
||||
super().__init__(metadata, required_parameters=["drama_name", "plot_analysis", "subtitle_content"])
|
||||
|
||||
@ -57,6 +64,11 @@ ${narration_language}
|
||||
${drama_genre}
|
||||
</drama_genre>
|
||||
|
||||
## 用户要求的文案字数
|
||||
<narration_word_count>
|
||||
${narration_word_count}
|
||||
</narration_word_count>
|
||||
|
||||
## 类型写作规则
|
||||
必须按用户选择的短剧类型调整表达重点,不要自行改判类型:
|
||||
- 霸总/甜宠:突出误会、身份差、暧昧拉扯、守护感和情绪反差。
|
||||
@ -81,7 +93,7 @@ ${drama_genre}
|
||||
4. 每句话只表达一个信息点,适合后续按句匹配画面。
|
||||
5. 句子尽量短,单句优先 15-35 字;信息复杂时拆成多句。
|
||||
6. 每 2-3 句要有明确因果承接,让观众知道为什么从上一幕来到下一幕。
|
||||
7. 总长度控制在 300-650 字;短素材取下限,长素材取上限。
|
||||
7. 总长度以 ${narration_word_count} 字为目标,允许上下浮动 10%;中日韩语言按非空白字符计数,其他语言按单词计数。不得再套用固定长度区间。
|
||||
8. 不要使用编号、项目符号、章节标题或括号说明。
|
||||
|
||||
## 输出要求
|
||||
|
||||
626
app/services/sonilo.py
Normal 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 Key(config.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
|
||||
@ -10,6 +10,8 @@ from typing import Any
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from app.config import config
|
||||
from app.config.defaults import resolve_text_model_name
|
||||
from app.services.llm.manager import LLMServiceManager
|
||||
from app.services.llm.migration_adapter import _run_async_safely
|
||||
from app.services.llm.unified_service import UnifiedLLMService
|
||||
@ -174,12 +176,16 @@ def correct_srt_content(
|
||||
provider: str = "",
|
||||
api_key: str = "",
|
||||
base_url: str = "",
|
||||
model_name: str = "",
|
||||
temperature: float = 0.1,
|
||||
) -> str:
|
||||
blocks = parse_srt_blocks(srt_content)
|
||||
_ensure_llm_providers_registered()
|
||||
|
||||
logger.info(f"开始校准字幕,共 {len(blocks)} 条")
|
||||
resolved_model_name = str(
|
||||
model_name or resolve_text_model_name(config.app, provider, prefer_fast=True)
|
||||
).strip()
|
||||
logger.info(f"开始使用高效率模型 {resolved_model_name} 校准字幕,共 {len(blocks)} 条")
|
||||
prompt = _build_correction_prompt(blocks)
|
||||
raw_output = _run_async_safely(
|
||||
UnifiedLLMService.generate_text,
|
||||
@ -190,6 +196,8 @@ def correct_srt_content(
|
||||
response_format="json",
|
||||
api_key=api_key,
|
||||
api_base=base_url,
|
||||
model=resolved_model_name,
|
||||
thinking_level="off",
|
||||
)
|
||||
corrections = _parse_corrections(raw_output, {block.order for block in blocks})
|
||||
corrected_srt = _render_srt(blocks, corrections)
|
||||
@ -215,6 +223,7 @@ def correct_subtitle_file(
|
||||
provider: str = "",
|
||||
api_key: str = "",
|
||||
base_url: str = "",
|
||||
model_name: str = "",
|
||||
temperature: float = 0.1,
|
||||
) -> str:
|
||||
if not subtitle_file or not os.path.isfile(subtitle_file):
|
||||
@ -226,6 +235,7 @@ def correct_subtitle_file(
|
||||
provider=provider,
|
||||
api_key=api_key,
|
||||
base_url=base_url,
|
||||
model_name=model_name,
|
||||
temperature=temperature,
|
||||
)
|
||||
return write_srt_file(corrected_srt, output_file)
|
||||
|
||||
383
app/services/subtitle_translator.py
Normal 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)
|
||||
@ -19,6 +19,7 @@ from app.services import (
|
||||
update_script,
|
||||
generate_video,
|
||||
script_subtitle,
|
||||
sonilo,
|
||||
)
|
||||
from app.services import state as sm
|
||||
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:
|
||||
"""Transcribe the fully merged video into an SRT file."""
|
||||
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}")
|
||||
|
||||
# 可选功能,默认关闭:混入 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 = utils.get_bgm_file(
|
||||
bgm_type=getattr(params, "bgm_type", "random"),
|
||||
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')
|
||||
@ -850,10 +890,10 @@ def start_subclip_unified(task_id: str, params: VideoClipParams):
|
||||
ffmpeg_progress=0,
|
||||
)
|
||||
|
||||
bgm_path = utils.get_bgm_file(
|
||||
bgm_type=getattr(params, "bgm_type", "random"),
|
||||
bgm_file=getattr(params, "bgm_file", ""),
|
||||
)
|
||||
# 可选功能,默认关闭:混入 Sonilo AI 音效(失败时沿用原视频)
|
||||
combined_video_path = _apply_sonilo_sfx(task_id, params, combined_video_path)
|
||||
|
||||
bgm_path = _resolve_bgm_path(task_id, params, combined_video_path)
|
||||
|
||||
# 获取优化的音量配置
|
||||
optimized_volumes = get_recommended_volumes_for_content('mixed')
|
||||
|
||||
116
app/services/test_doubaotts_tts_unittest.py
Normal 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()
|
||||
@ -408,6 +408,69 @@ class LocalFunAsrServiceTests(unittest.TestCase):
|
||||
self.assertEqual(123, session.calls[0][2]["timeout"])
|
||||
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):
|
||||
class LocalSession:
|
||||
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("世界。", 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):
|
||||
def test_request_local_firered_asr_posts_file_and_options(self):
|
||||
|
||||
211
app/services/test_indextts2_tts_unittest.py
Normal 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()
|
||||
102
app/services/test_indextts_macos_tts_unittest.py
Normal 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()
|
||||
@ -27,6 +27,14 @@ class SubtitleCorrectorTests(unittest.TestCase):
|
||||
}
|
||||
|
||||
with (
|
||||
mock.patch.dict(
|
||||
corrector.config.app,
|
||||
{
|
||||
"text_openai_model_name": "reasoning-model",
|
||||
"text_openai_fast_model_name": "fast-subtitle-model",
|
||||
},
|
||||
clear=False,
|
||||
),
|
||||
mock.patch("app.services.subtitle_corrector._ensure_llm_providers_registered"),
|
||||
mock.patch(
|
||||
"app.services.subtitle_corrector._run_async_safely",
|
||||
@ -49,6 +57,8 @@ class SubtitleCorrectorTests(unittest.TestCase):
|
||||
self.assertEqual("openai", call_kwargs["provider"])
|
||||
self.assertEqual("sk-test", call_kwargs["api_key"])
|
||||
self.assertEqual("https://llm.example/v1", call_kwargs["api_base"])
|
||||
self.assertEqual("fast-subtitle-model", call_kwargs["model"])
|
||||
self.assertEqual("off", call_kwargs["thinking_level"])
|
||||
self.assertEqual("json", call_kwargs["response_format"])
|
||||
self.assertIn("多语言字幕校对员", call_kwargs["system_prompt"])
|
||||
self.assertIn("保持原语言", call_kwargs["prompt"])
|
||||
|
||||
167
app/services/test_subtitle_translator_unittest.py
Normal 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()
|
||||
81
app/services/test_voxcpm2_tts_unittest.py
Normal 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()
|
||||
83
app/services/test_voxcpm_tts_unittest.py
Normal 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()
|
||||
@ -1150,14 +1150,13 @@ def doubaotts_tts(text: str, voice_name: str, voice_file: str, speed: float = 1.
|
||||
"""
|
||||
# 读取配置
|
||||
doubaotts_cfg = getattr(config, "doubaotts", {}) or {}
|
||||
api_key = (doubaotts_cfg.get("api_key", "") or doubaotts_cfg.get("apikey", "")).strip()
|
||||
appid = doubaotts_cfg.get("appid", "")
|
||||
token = doubaotts_cfg.get("token", "")
|
||||
ak = doubaotts_cfg.get("ak", "")
|
||||
sk = doubaotts_cfg.get("sk", "")
|
||||
cluster = doubaotts_cfg.get("cluster", "volcano_tts")
|
||||
|
||||
if not appid or not token:
|
||||
logger.error("豆包语音 TTS 配置未完成")
|
||||
if not api_key and (not appid or not token):
|
||||
logger.error("豆包语音 TTS 配置未完成,请配置 API Key,或填写旧版 AppID 和 Token")
|
||||
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)
|
||||
silence_duration = doubaotts_cfg.get("silence_duration", 0.125)
|
||||
|
||||
payload = {
|
||||
"app": {
|
||||
app_payload = {"cluster": cluster}
|
||||
if not api_key:
|
||||
app_payload.update({
|
||||
"appid": appid,
|
||||
"token": token,
|
||||
"cluster": cluster
|
||||
},
|
||||
})
|
||||
|
||||
payload = {
|
||||
"app": app_payload,
|
||||
"user": {
|
||||
"uid": "NarratoAI"
|
||||
},
|
||||
@ -1206,11 +1208,14 @@ def doubaotts_tts(text: str, voice_name: str, voice_file: str, speed: float = 1.
|
||||
# API 地址
|
||||
url = "https://openspeech.bytedance.com/api/v1/tts"
|
||||
|
||||
# 构建请求头(使用Bearer Token认证)
|
||||
# 构建请求头。新版控制台优先使用 API Key,旧配置继续使用 Token。
|
||||
headers = {
|
||||
"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):
|
||||
try:
|
||||
@ -1297,6 +1302,10 @@ def tts(
|
||||
logger.info("分发到 IndexTTS-1.5")
|
||||
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:
|
||||
logger.info("分发到 IndexTTS-2")
|
||||
return indextts2_tts(text, voice_name, voice_file)
|
||||
@ -1304,6 +1313,14 @@ def tts(
|
||||
if tts_engine == config.OMNIVOICE_ENGINE:
|
||||
logger.info("分发到 OmniVoice")
|
||||
return omnivoice_tts(text, voice_name, voice_file, speed=voice_rate)
|
||||
|
||||
if tts_engine == config.VOXCPM_ENGINE:
|
||||
logger.info("分发到 VoxCPM-0.5B")
|
||||
return voxcpm_tts(text, voice_name, voice_file)
|
||||
|
||||
if tts_engine == config.VOXCPM2_ENGINE:
|
||||
logger.info("分发到 VoxCPM-2B")
|
||||
return voxcpm2_tts(text, voice_name, voice_file)
|
||||
|
||||
if tts_engine == "doubaotts":
|
||||
logger.info("分发到豆包语音 TTS")
|
||||
@ -1791,8 +1808,11 @@ def tts_multiple(task_id: str, list_script: list, voice_name: str, voice_rate: f
|
||||
tts_results = []
|
||||
audio_extension = ".wav" if tts_engine in (
|
||||
config.INDEXTTS_ENGINE,
|
||||
config.INDEXTTS_MACOS_ENGINE,
|
||||
config.INDEXTTS2_ENGINE,
|
||||
config.OMNIVOICE_ENGINE,
|
||||
config.VOXCPM_ENGINE,
|
||||
config.VOXCPM2_ENGINE,
|
||||
) else ".mp3"
|
||||
|
||||
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 (
|
||||
is_soulvoice_voice(voice_name)
|
||||
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"
|
||||
):
|
||||
# 获取实际音频文件的时长
|
||||
@ -2266,6 +2293,13 @@ def parse_indextts2_voice(voice_name: str) -> str:
|
||||
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:
|
||||
"""
|
||||
解析 OmniVoice 语音名称
|
||||
@ -2277,6 +2311,20 @@ def parse_omnivoice_voice(voice_name: str) -> str:
|
||||
return voice_name
|
||||
|
||||
|
||||
def parse_voxcpm_voice(voice_name: str) -> str:
|
||||
"""解析 VoxCPM-0.5B 的可选参考音频路径。"""
|
||||
if isinstance(voice_name, str) and voice_name.startswith(config.VOXCPM_VOICE_PREFIX):
|
||||
return voice_name[len(config.VOXCPM_VOICE_PREFIX):]
|
||||
return voice_name
|
||||
|
||||
|
||||
def parse_voxcpm2_voice(voice_name: str) -> str:
|
||||
"""解析 VoxCPM-2B 的模式或参考音频路径。"""
|
||||
if isinstance(voice_name, str) and voice_name.startswith(config.VOXCPM2_VOICE_PREFIX):
|
||||
return voice_name[len(config.VOXCPM2_VOICE_PREFIX):]
|
||||
return voice_name
|
||||
|
||||
|
||||
def indextts_tts(text: str, voice_name: str, voice_file: str, speed: float = 1.0) -> Union[SubMaker, None]:
|
||||
"""
|
||||
使用 IndexTTS-1.5 API 进行零样本语音克隆
|
||||
@ -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:
|
||||
api_url = (api_url or "http://192.168.3.6:7863/tts").strip()
|
||||
if api_url.endswith("/tts"):
|
||||
"""Return the IndexTTS-2 MLX Pack upload endpoint for a configured URL.
|
||||
|
||||
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 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:
|
||||
@ -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:
|
||||
content_type = response.headers.get("content-type", "").lower()
|
||||
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)
|
||||
return os.path.getsize(voice_file) > 0
|
||||
|
||||
result = response.json()
|
||||
downloads = result.get("downloads") if isinstance(result, dict) else {}
|
||||
download_url = downloads.get("wav") if isinstance(downloads, dict) else ""
|
||||
try:
|
||||
result = response.json()
|
||||
except ValueError:
|
||||
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:
|
||||
logger.error(f"IndexTTS-2 API 响应中没有音频下载地址: {result}")
|
||||
return False
|
||||
@ -2432,68 +2684,74 @@ 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]:
|
||||
"""
|
||||
使用 IndexTTS-2 API 进行零样本语音克隆。
|
||||
接口兼容 IndexTTS2-Pack 的 POST /tts multipart form。
|
||||
使用 IndexTTS-2 MLX Pack API 进行零样本语音克隆。
|
||||
|
||||
参考音频通过 ``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)
|
||||
|
||||
if not reference_audio_path or not os.path.exists(reference_audio_path):
|
||||
logger.error(f"IndexTTS-2 参考音频文件不存在: {reference_audio_path}")
|
||||
return None
|
||||
|
||||
emotion_mode = config.indextts2.get("emotion_mode", "speaker")
|
||||
emotion_audio_path = config.indextts2.get("emotion_audio", "")
|
||||
data = {
|
||||
"text": text.strip(),
|
||||
"emotion_mode": emotion_mode,
|
||||
"emotion_alpha": config.indextts2.get("emotion_alpha", 0.65),
|
||||
"emotion_text": config.indextts2.get("emotion_text", ""),
|
||||
"use_random": str(bool(config.indextts2.get("use_random", False))).lower(),
|
||||
"max_text_tokens_per_segment": config.indextts2.get("max_text_tokens_per_segment", 120),
|
||||
"vec_happy": config.indextts2.get("vec_happy", 0.0),
|
||||
"vec_angry": config.indextts2.get("vec_angry", 0.0),
|
||||
"vec_sad": config.indextts2.get("vec_sad", 0.0),
|
||||
"vec_afraid": config.indextts2.get("vec_afraid", 0.0),
|
||||
"vec_disgusted": config.indextts2.get("vec_disgusted", 0.0),
|
||||
"vec_melancholic": config.indextts2.get("vec_melancholic", 0.0),
|
||||
"vec_surprised": config.indextts2.get("vec_surprised", 0.0),
|
||||
"vec_calm": config.indextts2.get("vec_calm", 0.8),
|
||||
"temperature": config.indextts2.get("temperature", 0.8),
|
||||
"top_p": config.indextts2.get("top_p", 0.8),
|
||||
"top_k": config.indextts2.get("top_k", 30),
|
||||
"num_beams": config.indextts2.get("num_beams", 3),
|
||||
"repetition_penalty": config.indextts2.get("repetition_penalty", 10.0),
|
||||
"max_mel_tokens": config.indextts2.get("max_mel_tokens", 1500),
|
||||
"emo_alpha": _get_indextts2_number(
|
||||
"emo_alpha", config.indextts2.get("emotion_alpha", 0.6), 0.0, 1.0
|
||||
),
|
||||
"speed": _get_indextts2_number("speed", 1.0, 0.5, 2.0),
|
||||
"max_mel_tokens": _get_indextts2_number(
|
||||
"max_mel_tokens", 1500, 64, 1815, integer=True
|
||||
),
|
||||
"max_text_tokens_per_segment": _get_indextts2_number(
|
||||
"max_text_tokens_per_segment", 120, 20, 600, integer=True
|
||||
),
|
||||
"interval_silence": _get_indextts2_number(
|
||||
"interval_silence", 200, 0, 5000, integer=True
|
||||
),
|
||||
"temperature": _get_indextts2_number("temperature", 0.8, 0.05, 2.0),
|
||||
"top_p": _get_indextts2_number("top_p", 0.8, 0.05, 1.0),
|
||||
"top_k": _get_indextts2_number("top_k", 30, 1, 200, integer=True),
|
||||
"repetition_penalty": _get_indextts2_number(
|
||||
"repetition_penalty", 10.0, 1.0, 30.0
|
||||
),
|
||||
"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()
|
||||
for attempt in range(3):
|
||||
files = {}
|
||||
try:
|
||||
files["speaker_audio"] = open(reference_audio_path, "rb")
|
||||
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")
|
||||
with open(reference_audio_path, "rb") as reference_audio:
|
||||
logger.info(f"第 {attempt + 1} 次调用 IndexTTS-2 API: {api_url}")
|
||||
response = requests.post(
|
||||
api_url,
|
||||
files={"reference_audio": reference_audio},
|
||||
data=data,
|
||||
proxies=proxies,
|
||||
timeout=180,
|
||||
)
|
||||
|
||||
logger.info(f"第 {attempt + 1} 次调用 IndexTTS-2 API: {api_url}")
|
||||
response = requests.post(
|
||||
api_url,
|
||||
files=files,
|
||||
data=data,
|
||||
proxies=proxies,
|
||||
timeout=180,
|
||||
)
|
||||
|
||||
if response.status_code == 200 and _download_indextts2_audio(response, api_url, voice_file, proxies):
|
||||
logger.info(f"IndexTTS-2 成功生成音频: {voice_file}, 大小: {os.path.getsize(voice_file)} 字节")
|
||||
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
|
||||
if response.status_code == 200 and _download_indextts2_audio(response, api_url, voice_file, proxies):
|
||||
logger.info(f"IndexTTS-2 成功生成音频: {voice_file}, 大小: {os.path.getsize(voice_file)} 字节")
|
||||
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-2 API 调用失败: {response.status_code} - {response.text}")
|
||||
except requests.exceptions.Timeout:
|
||||
@ -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)")
|
||||
except Exception as e:
|
||||
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:
|
||||
time.sleep(2)
|
||||
@ -2516,6 +2768,144 @@ def indextts2_tts(text: str, voice_name: str, voice_file: str) -> Union[SubMaker
|
||||
return None
|
||||
|
||||
|
||||
def _normalize_voxcpm_api_url(api_url: str) -> str:
|
||||
api_url = (api_url or "http://127.0.0.1:7864").strip().rstrip("/")
|
||||
if api_url.endswith("/v1/audio/speech"):
|
||||
api_url = api_url[:-len("/v1/audio/speech")]
|
||||
if api_url.endswith("/tts"):
|
||||
return api_url
|
||||
return f"{api_url}/tts"
|
||||
|
||||
|
||||
def voxcpm_tts(text: str, voice_name: str, voice_file: str) -> Union[SubMaker, None]:
|
||||
"""使用 VoxCPM-0.5B-Pack 的 multipart /tts 接口生成 WAV。"""
|
||||
voxcpm_config = getattr(config, "voxcpm_05b", {}) or {}
|
||||
api_url = _normalize_voxcpm_api_url(voxcpm_config.get("api_url", "http://127.0.0.1:7864"))
|
||||
reference_audio = parse_voxcpm_voice(voice_name)
|
||||
if reference_audio in ("", "default") or not os.path.isfile(reference_audio):
|
||||
reference_audio = voxcpm_config.get("reference_audio", "") or ""
|
||||
if reference_audio and not os.path.isfile(reference_audio):
|
||||
logger.error(f"VoxCPM-0.5B 参考音频文件不存在: {reference_audio}")
|
||||
return None
|
||||
|
||||
data = {"text": text.strip()}
|
||||
optional_fields = {
|
||||
"prompt_text": voxcpm_config.get("prompt_text"),
|
||||
"cfg_value": voxcpm_config.get("cfg_value"),
|
||||
"inference_timesteps": voxcpm_config.get("inference_timesteps"),
|
||||
"max_length": voxcpm_config.get("max_length"),
|
||||
}
|
||||
for key, value in optional_fields.items():
|
||||
if value not in (None, ""):
|
||||
data[key] = value
|
||||
for key in ("normalize", "denoise"):
|
||||
if key in voxcpm_config:
|
||||
data[key] = str(bool(voxcpm_config[key])).lower()
|
||||
|
||||
proxies = _get_configured_proxies()
|
||||
for attempt in range(3):
|
||||
files = {}
|
||||
try:
|
||||
if reference_audio:
|
||||
files["prompt_audio"] = open(reference_audio, "rb")
|
||||
response = requests.post(api_url, data=data, files=files or None, proxies=proxies, timeout=300)
|
||||
if response.status_code == 200:
|
||||
result = response.json()
|
||||
audio_url = result.get("audio_url", "") if isinstance(result, dict) else ""
|
||||
if audio_url:
|
||||
audio_response = requests.get(urljoin(api_url, audio_url), proxies=proxies, timeout=180)
|
||||
if audio_response.status_code == 200:
|
||||
with open(voice_file, "wb") as output:
|
||||
output.write(audio_response.content)
|
||||
if os.path.getsize(voice_file) > 0:
|
||||
sub_maker = new_sub_maker()
|
||||
duration = get_audio_duration_from_file(voice_file)
|
||||
duration_ms = int(duration * 1000) if duration > 0 else max(1000, len(text) * 200)
|
||||
add_subtitle_event(sub_maker, 0, duration_ms * 10000, text)
|
||||
return sub_maker
|
||||
logger.error(f"VoxCPM-0.5B API 响应中没有有效音频地址: {result}")
|
||||
else:
|
||||
logger.error(f"VoxCPM-0.5B API 调用失败: {response.status_code} - {response.text}")
|
||||
except (ValueError, requests.exceptions.RequestException) as exc:
|
||||
logger.error(f"VoxCPM-0.5B TTS 请求失败 (尝试 {attempt + 1}/3): {exc}")
|
||||
finally:
|
||||
for file_obj in files.values():
|
||||
file_obj.close()
|
||||
if attempt < 2:
|
||||
time.sleep(2)
|
||||
return None
|
||||
|
||||
|
||||
def _normalize_voxcpm2_api_url(api_url: str) -> str:
|
||||
api_url = (api_url or "http://127.0.0.1:7863").strip().rstrip("/")
|
||||
for suffix in ("/v1/audio/speech", "/tts/batch"):
|
||||
if api_url.endswith(suffix):
|
||||
api_url = api_url[:-len(suffix)]
|
||||
return api_url if api_url.endswith("/tts") else f"{api_url}/tts"
|
||||
|
||||
|
||||
def voxcpm2_tts(text: str, voice_name: str, voice_file: str) -> Union[SubMaker, None]:
|
||||
"""使用 VoxCPM-2B-Pack 的 /tts 接口进行音色设计或参考音频克隆。"""
|
||||
pack_config = getattr(config, "voxcpm_2b", {}) or {}
|
||||
api_url = _normalize_voxcpm2_api_url(pack_config.get("api_url", "http://127.0.0.1:7863"))
|
||||
mode = str(pack_config.get("mode", "design"))
|
||||
parsed_voice = parse_voxcpm2_voice(voice_name)
|
||||
reference_audio = ""
|
||||
if mode == "clone":
|
||||
reference_audio = parsed_voice if parsed_voice and os.path.isfile(parsed_voice) else pack_config.get("reference_audio", "")
|
||||
if not reference_audio or not os.path.isfile(reference_audio):
|
||||
logger.error(f"VoxCPM-2B 参考音频文件不存在: {reference_audio}")
|
||||
return None
|
||||
|
||||
data = {"text": text.strip()}
|
||||
optional_fields = {
|
||||
"control": pack_config.get("control"),
|
||||
"prompt_text": pack_config.get("prompt_text") if mode == "clone" else None,
|
||||
"cfg_value": pack_config.get("cfg_value"),
|
||||
"inference_timesteps": pack_config.get("inference_timesteps"),
|
||||
}
|
||||
for key, value in optional_fields.items():
|
||||
if value not in (None, ""):
|
||||
data[key] = value
|
||||
for key in ("normalize", "denoise", "output_48k", "context_aware", "streaming"):
|
||||
if key in pack_config:
|
||||
data[key] = str(bool(pack_config[key])).lower()
|
||||
|
||||
proxies = _get_configured_proxies()
|
||||
for attempt in range(3):
|
||||
files = {}
|
||||
try:
|
||||
if reference_audio:
|
||||
files["reference_audio"] = open(reference_audio, "rb")
|
||||
response = requests.post(api_url, data=data, files=files or None, proxies=proxies, timeout=600)
|
||||
if response.status_code == 200:
|
||||
result = response.json()
|
||||
downloads = result.get("downloads", {}) if isinstance(result, dict) else {}
|
||||
audio_url = downloads.get("wav", "") if isinstance(downloads, dict) else ""
|
||||
if audio_url:
|
||||
audio_response = requests.get(urljoin(api_url, audio_url), proxies=proxies, timeout=180)
|
||||
if audio_response.status_code == 200:
|
||||
with open(voice_file, "wb") as output:
|
||||
output.write(audio_response.content)
|
||||
if os.path.getsize(voice_file) > 0:
|
||||
sub_maker = new_sub_maker()
|
||||
duration = get_audio_duration_from_file(voice_file)
|
||||
duration_ms = int(duration * 1000) if duration > 0 else max(1000, len(text) * 200)
|
||||
add_subtitle_event(sub_maker, 0, duration_ms * 10000, text)
|
||||
return sub_maker
|
||||
logger.error(f"VoxCPM-2B API 响应中没有有效 WAV 下载地址: {result}")
|
||||
else:
|
||||
logger.error(f"VoxCPM-2B API 调用失败: {response.status_code} - {response.text}")
|
||||
except (ValueError, requests.exceptions.RequestException) as exc:
|
||||
logger.error(f"VoxCPM-2B TTS 请求失败 (尝试 {attempt + 1}/3): {exc}")
|
||||
finally:
|
||||
for file_obj in files.values():
|
||||
file_obj.close()
|
||||
if attempt < 2:
|
||||
time.sleep(2)
|
||||
return None
|
||||
|
||||
|
||||
def _normalize_omnivoice_api_url(api_url: str) -> str:
|
||||
api_url = (api_url or "http://127.0.0.1:7866/tts").strip()
|
||||
if api_url.endswith("/tts"):
|
||||
|
||||
@ -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
|
||||
@ -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))
|
||||
111
app/utils/openai_base_url_security.py
Normal 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)
|
||||
@ -1,10 +1,12 @@
|
||||
[app]
|
||||
project_version="0.7.8"
|
||||
project_version="0.8.4"
|
||||
|
||||
# LLM API 超时配置(秒)
|
||||
llm_vision_timeout = 120 # 视觉模型基础超时时间
|
||||
llm_text_timeout = 180 # 文本模型基础超时时间(解说文案生成等复杂任务需要更长时间)
|
||||
llm_max_retries = 3 # API 重试次数
|
||||
subtitle_translate_batch_size = 20 # 字幕翻译每批处理的字幕条数
|
||||
subtitle_translate_max_workers = 3 # 字幕翻译最大并发批次数
|
||||
|
||||
##########################################
|
||||
# 🚀 LLM 配置 - 使用 OpenAI 兼容统一接口
|
||||
@ -24,12 +26,22 @@
|
||||
# - SiliconFlow: siliconflow/Qwen/Qwen2.5-VL-32B-Instruct
|
||||
vision_openai_model_name = "Qwen/Qwen3.5-122B-A10B"
|
||||
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_top_p = 0.95
|
||||
vision_openai_max_tokens = 65536
|
||||
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"
|
||||
|
||||
@ -41,9 +53,10 @@
|
||||
# - Qwen: qwen/qwen-plus, qwen/qwen-turbo
|
||||
# - SiliconFlow: siliconflow/deepseek-ai/DeepSeek-R1
|
||||
# - Moonshot: moonshot/moonshot-v1-8k
|
||||
text_openai_model_name = "Pro/zai-org/GLM-5"
|
||||
text_openai_model_name = "Pro/zai-org/GLM-5" # 高推理模型:剧情分析、文案生成、脚本匹配
|
||||
text_openai_fast_model_name = "" # 高效率模型:字幕翻译、字幕校准;留空时回退到高推理模型
|
||||
text_openai_api_key = "" # 填入对应 provider 的 API key
|
||||
text_openai_base_url = "https://api.siliconflow.cn/v1" # 可选:自定义 API base URL(官方 OpenAI 可留空)
|
||||
text_openai_base_url = "https://api.siliconflow.cn/v1" # 可选:自定义 API base URL;界面会提示 API key 将发送到对应端点
|
||||
text_openai_temperature = 1.0
|
||||
text_openai_top_p = 0.95
|
||||
text_openai_max_tokens = 65536
|
||||
@ -55,6 +68,20 @@
|
||||
tavily_search_depth = "basic" # basic / advanced / fast / ultra-fast
|
||||
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 参考 =====
|
||||
# 主流 LLM Providers API Key 获取地址:
|
||||
#
|
||||
@ -117,6 +144,7 @@
|
||||
# backend = "local" 使用本地 FunASR-Pack API;backend = "firered" 使用本地 FireRedASR2-AED-Pack API;backend = "bailian" 使用阿里百炼在线 fun-asr
|
||||
auto_transcribe_enabled = false
|
||||
backend = "local"
|
||||
# 支持填写服务根地址、完整 /asr 地址,或 OpenAI-compatible /v1 地址
|
||||
api_url = "http://127.0.0.1:7860"
|
||||
firered_api_url = "http://127.0.0.1:7867"
|
||||
hotword = ""
|
||||
@ -147,40 +175,51 @@
|
||||
num_beams = 3
|
||||
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]
|
||||
# IndexTTS-2 语音克隆配置
|
||||
# 支持 IndexTTS2-Pack FastAPI 接口:POST /tts
|
||||
api_url = "http://192.168.3.6:7863/tts"
|
||||
# IndexTTS-2 MLX Pack 语音克隆配置
|
||||
# 参考音频会通过 POST /v1/audio/speech/upload 上传;可填写服务根地址或完整接口地址
|
||||
api_url = "http://127.0.0.1:7860"
|
||||
|
||||
# 默认参考音频(可选),音色列表复用 IndexTTS-1.5 的资源目录
|
||||
reference_audio_source = "resource"
|
||||
# reference_audio = "/path/to/reference_audio.wav"
|
||||
|
||||
# 情感控制:speaker / audio / vector / text
|
||||
emotion_mode = "speaker"
|
||||
emotion_audio = ""
|
||||
emotion_alpha = 0.65
|
||||
emotion_text = ""
|
||||
use_random = false
|
||||
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
|
||||
# 留空时保留参考音频情绪;支持单个情绪或混合权重,例如 happy:0.7,calm:0.3
|
||||
emotion = ""
|
||||
emo_alpha = 0.6
|
||||
speed = 1.0
|
||||
# 可选:设置固定随机种子以复现生成结果
|
||||
# seed = 20260713
|
||||
|
||||
# 高级生成参数
|
||||
max_text_tokens_per_segment = 120
|
||||
interval_silence = 200
|
||||
temperature = 0.8
|
||||
top_p = 0.8
|
||||
top_k = 30
|
||||
num_beams = 3
|
||||
repetition_penalty = 10.0
|
||||
max_mel_tokens = 1500
|
||||
diffusion_steps = 25
|
||||
cfg_rate = 0.7
|
||||
segment_overlap_ms = 50
|
||||
|
||||
[omnivoice]
|
||||
# OmniVoice-Pack 语音合成配置
|
||||
@ -206,12 +245,45 @@
|
||||
postprocess_output = true
|
||||
preprocess_prompt = true
|
||||
|
||||
[voxcpm_05b]
|
||||
# VoxCPM-0.5B-Pack 本地语音合成/参考音频克隆配置
|
||||
# 启动整合包后,可填写服务根地址或完整 /tts 地址
|
||||
api_url = "http://127.0.0.1:7864"
|
||||
reference_audio_source = "resource"
|
||||
reference_audio = ""
|
||||
prompt_text = ""
|
||||
cfg_value = 2.0
|
||||
inference_timesteps = 10
|
||||
max_length = 4096
|
||||
normalize = true
|
||||
denoise = false
|
||||
|
||||
[voxcpm_2b]
|
||||
# VoxCPM-2B-Pack 本地语音合成、音色设计与参考音频克隆
|
||||
api_url = "http://127.0.0.1:7863"
|
||||
mode = "design"
|
||||
control = ""
|
||||
reference_audio_source = "resource"
|
||||
reference_audio = ""
|
||||
prompt_text = ""
|
||||
cfg_value = 2.0
|
||||
inference_timesteps = 10
|
||||
normalize = true
|
||||
denoise = false
|
||||
output_48k = true
|
||||
context_aware = true
|
||||
streaming = false
|
||||
|
||||
[doubaotts]
|
||||
# 豆包语音 TTS 配置
|
||||
# 新版配置优先填写 API Key;旧版 appid/token 配置仍兼容
|
||||
# 申请流程:
|
||||
# 1. 打开 https://console.volcengine.com/iam/keymanage 新建 Access Key 和 Secret Key
|
||||
# 2. 打开 https://www.volcengine.com/product/voice-tech 点击立即使用
|
||||
# 3. 在 API 服务中心找到音频生成下面的语音合成,获取 APPID 和 Token
|
||||
# 1. 打开火山引擎豆包语音控制台
|
||||
# 2. 进入 API Key 管理并创建 API Key
|
||||
# 3. 确认已开通豆包语音合成服务
|
||||
api_key = ""
|
||||
|
||||
# 旧版配置(兼容保留)
|
||||
ak = ""
|
||||
sk = ""
|
||||
appid = ""
|
||||
|
||||
|
Before Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 102 KiB |
|
Before Width: | Height: | Size: 109 KiB |
|
Before Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 916 KiB |
|
Before Width: | Height: | Size: 696 KiB |
|
Before Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 144 KiB |
|
Before Width: | Height: | Size: 166 KiB |
|
Before Width: | Height: | Size: 143 KiB |
|
Before Width: | Height: | Size: 468 KiB |
|
Before Width: | Height: | Size: 300 KiB |
@ -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
|
||||
@ -1 +1 @@
|
||||
0.8.2
|
||||
0.8.7
|
||||
50
pyproject.toml
Normal 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
|
||||
@ -25,6 +25,9 @@ tqdm>=4.66.6
|
||||
tenacity>=9.0.0
|
||||
|
||||
# 可选依赖(根据功能需要)
|
||||
# 如果使用 TwelveLabs Pegasus 视频理解作为视觉提供商,取消注释下面的行
|
||||
# twelvelabs>=1.2.8
|
||||
|
||||
# 如果需要本地语音识别,取消注释下面的行
|
||||
# faster-whisper>=1.0.1
|
||||
|
||||
|
||||
163
tests/test_sonilo_bgm_unittest.py
Normal 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()
|
||||
362
tests/test_sonilo_sfx_unittest.py
Normal 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()
|
||||
84
tests/test_streamlit_widget_session_state.py
Normal 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)
|
||||
@ -9,12 +9,17 @@ from app.config.defaults import (
|
||||
DEFAULT_OPENAI_COMPATIBLE_BASE_URL,
|
||||
DEFAULT_OPENAI_COMPATIBLE_PROVIDER,
|
||||
DEFAULT_TEXT_LLM_PROVIDER,
|
||||
DEFAULT_TEXT_OPENAI_FAST_MODEL_NAME,
|
||||
DEFAULT_TEXT_OPENAI_MODEL_NAME,
|
||||
DEFAULT_VISION_LLM_PROVIDER,
|
||||
DEFAULT_VISION_OPENAI_MODEL_NAME,
|
||||
get_openai_compatible_ui_values,
|
||||
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 loguru import logger
|
||||
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://')):
|
||||
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, ""
|
||||
|
||||
|
||||
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]:
|
||||
"""验证模型名称"""
|
||||
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)':
|
||||
# OpenAI兼容的Gemini代理测试
|
||||
try:
|
||||
base_url = validate_openai_compatible_base_url(base_url)
|
||||
headers = {
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"Content-Type": "application/json"
|
||||
@ -444,6 +461,7 @@ def test_vision_model_connection(api_key, base_url, model_name, provider, tr):
|
||||
else:
|
||||
from openai import OpenAI
|
||||
try:
|
||||
base_url = validate_openai_compatible_base_url(base_url)
|
||||
client = OpenAI(
|
||||
api_key=api_key,
|
||||
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
|
||||
|
||||
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(
|
||||
api_key=api_key,
|
||||
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
|
||||
|
||||
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(
|
||||
api_key=api_key,
|
||||
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:
|
||||
info_example = vision_placeholder or "https://your-openai-compatible-endpoint/v1"
|
||||
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")
|
||||
|
||||
@ -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_fast_model_name = (
|
||||
config.app.get("text_openai_fast_model_name")
|
||||
or DEFAULT_TEXT_OPENAI_FAST_MODEL_NAME
|
||||
)
|
||||
text_api_key = config.app.get("text_openai_api_key", "")
|
||||
text_base_url = config.app.get("text_openai_base_url", DEFAULT_OPENAI_COMPATIBLE_BASE_URL)
|
||||
|
||||
@ -865,10 +890,14 @@ def render_text_llm_settings(tr):
|
||||
DEFAULT_TEXT_OPENAI_MODEL_NAME,
|
||||
provider=DEFAULT_TEXT_LLM_PROVIDER,
|
||||
)
|
||||
current_fast_model = normalize_openai_compatible_model_id(
|
||||
full_fast_model_name,
|
||||
provider=DEFAULT_TEXT_LLM_PROVIDER,
|
||||
)
|
||||
selected_provider = DEFAULT_TEXT_LLM_PROVIDER
|
||||
|
||||
# 渲染配置输入框
|
||||
col1, col2 = st.columns([1, 2])
|
||||
col1, col2, col3 = st.columns([1, 2, 2])
|
||||
with col1:
|
||||
render_openai_compatible_protocol_field(
|
||||
tr,
|
||||
@ -877,11 +906,13 @@ def render_text_llm_settings(tr):
|
||||
)
|
||||
|
||||
with col2:
|
||||
model_name_input = st.text_input(
|
||||
tr("Text Model Name"),
|
||||
reasoning_model_name_input = st.text_input(
|
||||
tr("High Reasoning Model Name"),
|
||||
value=current_model,
|
||||
help=(
|
||||
tr("Model Name Input Help")
|
||||
tr("High Reasoning Model Help")
|
||||
+ "\n\n"
|
||||
+ tr("Model Name Input Help")
|
||||
+ "\n\n"
|
||||
+ "• Pro/zai-org/GLM-5\n"
|
||||
+ "• deepseek/deepseek-chat\n"
|
||||
@ -892,8 +923,24 @@ def render_text_llm_settings(tr):
|
||||
key="text_model_input"
|
||||
)
|
||||
|
||||
with col3:
|
||||
fast_model_name_input = st.text_input(
|
||||
tr("High Efficiency Model Name"),
|
||||
value=current_fast_model,
|
||||
help=(
|
||||
tr("High Efficiency Model Help")
|
||||
+ "\n\n"
|
||||
+ "• Qwen/Qwen3.5-32B\n"
|
||||
+ "• gpt-4o-mini\n"
|
||||
+ "• gemini-2.5-flash\n"
|
||||
+ "• deepseek/deepseek-chat"
|
||||
),
|
||||
key="text_fast_model_input",
|
||||
)
|
||||
|
||||
# 组合完整的模型名称
|
||||
st_text_model_name = normalize_openai_compatible_model_name(model_name_input)
|
||||
st_text_model_name = normalize_openai_compatible_model_name(reasoning_model_name_input)
|
||||
st_text_fast_model_name = normalize_openai_compatible_model_name(fast_model_name_input)
|
||||
|
||||
st_text_api_key = st.text_input(
|
||||
tr("Text API Key"),
|
||||
@ -923,6 +970,7 @@ def render_text_llm_settings(tr):
|
||||
if text_base_required and not st_text_base_url:
|
||||
info_example = text_placeholder or "https://your-openai-compatible-endpoint/v1"
|
||||
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")
|
||||
|
||||
@ -931,7 +979,7 @@ def render_text_llm_settings(tr):
|
||||
test_errors = []
|
||||
if not st_text_api_key:
|
||||
test_errors.append(tr("Please enter API key"))
|
||||
if not model_name_input:
|
||||
if not reasoning_model_name_input:
|
||||
test_errors.append(tr("Please enter model name"))
|
||||
|
||||
if test_errors:
|
||||
@ -940,17 +988,25 @@ def render_text_llm_settings(tr):
|
||||
else:
|
||||
with st.spinner(tr("Testing connection...")):
|
||||
try:
|
||||
success, message = test_openai_compatible_text_model(
|
||||
api_key=st_text_api_key,
|
||||
base_url=st_text_base_url,
|
||||
model_name=st_text_model_name,
|
||||
tr=tr
|
||||
)
|
||||
|
||||
if success:
|
||||
st.success(message)
|
||||
else:
|
||||
st.error(message)
|
||||
test_targets = [
|
||||
(tr("High Reasoning Model Name"), st_text_model_name),
|
||||
]
|
||||
if st_text_fast_model_name:
|
||||
test_targets.append((
|
||||
tr("High Efficiency Model Name"),
|
||||
st_text_fast_model_name,
|
||||
))
|
||||
for label, target_model in test_targets:
|
||||
success, message = test_openai_compatible_text_model(
|
||||
api_key=st_text_api_key,
|
||||
base_url=st_text_base_url,
|
||||
model_name=target_model,
|
||||
tr=tr,
|
||||
)
|
||||
if success:
|
||||
st.success(f"{label}: {message}")
|
||||
else:
|
||||
st.error(f"{label}: {message}")
|
||||
except Exception as e:
|
||||
st.error(f"{tr('Connection test error')}: {str(e)}")
|
||||
logger.error(f"OpenAI 兼容 文案生成模型连接测试失败: {str(e)}")
|
||||
@ -971,6 +1027,25 @@ def render_text_llm_settings(tr):
|
||||
else:
|
||||
text_validation_errors.append(error_msg)
|
||||
|
||||
if st_text_fast_model_name:
|
||||
is_valid, error_msg = validate_openai_compatible_model_name(
|
||||
st_text_fast_model_name,
|
||||
"高效率文案生成",
|
||||
)
|
||||
if is_valid:
|
||||
text_config_changed |= update_app_config_if_changed(
|
||||
"text_openai_fast_model_name",
|
||||
st_text_fast_model_name,
|
||||
)
|
||||
st.session_state["text_openai_fast_model_name"] = st_text_fast_model_name
|
||||
else:
|
||||
text_validation_errors.append(error_msg)
|
||||
else:
|
||||
text_config_changed |= update_app_config_if_changed(
|
||||
"text_openai_fast_model_name",
|
||||
"",
|
||||
)
|
||||
|
||||
# 验证 API 密钥
|
||||
if st_text_api_key:
|
||||
is_valid, error_msg = validate_api_key(st_text_api_key, "文案生成")
|
||||
@ -1006,7 +1081,7 @@ def render_text_llm_settings(tr):
|
||||
config.save_config()
|
||||
# 清除缓存,确保下次使用新配置
|
||||
UnifiedLLMService.clear_cache()
|
||||
if st_text_api_key or st_text_base_url or st_text_model_name:
|
||||
if st_text_api_key or st_text_base_url or st_text_model_name or st_text_fast_model_name:
|
||||
st.success(tr("Text model config saved"))
|
||||
except Exception as e:
|
||||
st.error(f"{tr('Failed to save config')}: {str(e)}")
|
||||
|
||||
@ -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_TEXT_COLUMNS = {"video_name", "timestamp", "picture", "narration", "value"}
|
||||
MODE_FILE = "file_selection"
|
||||
MODE_AUTO = "auto"
|
||||
MODE_SHORT = "short"
|
||||
@ -96,6 +97,9 @@ FILM_TV_TYPE_VALUES = {
|
||||
"horror_thriller": "恐怖/惊悚",
|
||||
}
|
||||
SHORT_DRAMA_ORIGINAL_SOUND_RATIO_OPTIONS = list(range(0, 100, 10))
|
||||
DEFAULT_NARRATION_WORD_COUNT = 500
|
||||
MIN_NARRATION_WORD_COUNT = 100
|
||||
MAX_NARRATION_WORD_COUNT = 5000
|
||||
SUMMARY_MODE_CONFIGS = {
|
||||
MODE_FILM_SUMMARY: {
|
||||
"mode_label_key": "Film TV Narration",
|
||||
@ -205,6 +209,14 @@ def _format_file_list_for_display(paths, max_items=3):
|
||||
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):
|
||||
try:
|
||||
return read_subtitle_text(path).text
|
||||
@ -658,16 +670,42 @@ def render_short_generate_options(tr):
|
||||
渲染Short Generate模式下的特殊选项
|
||||
在Short Generate模式下,替换原有的输入框为自定义片段选项
|
||||
"""
|
||||
summary_narration_panel(tr, SUMMARY_MODE_CONFIGS[MODE_SHORT_SUMMARY])
|
||||
# 显示自定义片段数量选择器
|
||||
custom_clips = st.number_input(
|
||||
tr("自定义片段"),
|
||||
min_value=1,
|
||||
max_value=20,
|
||||
value=st.session_state.get('custom_clips', 5),
|
||||
help=tr("设置需要生成的短视频片段数量"),
|
||||
key="custom_clips_input"
|
||||
)
|
||||
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(
|
||||
tr("自定义片段"),
|
||||
min_value=1,
|
||||
max_value=20,
|
||||
value=st.session_state.get('custom_clips', 5),
|
||||
help=tr("设置需要生成的短视频片段数量"),
|
||||
key="custom_clips_input"
|
||||
)
|
||||
st.session_state['custom_clips'] = custom_clips
|
||||
|
||||
|
||||
@ -721,6 +759,7 @@ def summary_narration_panel(tr, summary_config):
|
||||
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")
|
||||
|
||||
st.markdown(
|
||||
f"""
|
||||
@ -807,6 +846,15 @@ def summary_narration_panel(tr, summary_config):
|
||||
st.session_state[plot_analysis_key] = ""
|
||||
st.session_state[plot_source_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:
|
||||
with st.spinner(tr("Analyzing plot...")):
|
||||
@ -995,10 +1043,17 @@ def _script_json_to_table(script_data):
|
||||
{"value": json.dumps(item, ensure_ascii=False)}
|
||||
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)
|
||||
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):
|
||||
@ -1144,6 +1199,8 @@ def render_fun_asr_transcription(tr):
|
||||
)
|
||||
backend = backend_options[backend_label]
|
||||
|
||||
st.markdown(tr("Subtitle transcription package downloads"))
|
||||
|
||||
if backend == "upload":
|
||||
render_subtitle_upload(tr)
|
||||
elif backend == "local":
|
||||
@ -1211,23 +1268,41 @@ def render_fun_asr_transcription(tr):
|
||||
# 上传字幕面板会在本轮渲染中更新 session_state,这里重新读取一次,保证按钮状态同步。
|
||||
subtitle_paths = _selected_subtitle_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]:
|
||||
action_cols = st.columns(2)
|
||||
with action_cols[0]:
|
||||
transcribe_clicked = st.button(
|
||||
tr("Transcribe subtitles"),
|
||||
key="fun_asr_transcribe",
|
||||
disabled=not can_transcribe,
|
||||
use_container_width=True,
|
||||
)
|
||||
with action_cols[1]:
|
||||
correct_clicked = st.button(
|
||||
tr("Calibrate subtitles"),
|
||||
key="subtitle_correct",
|
||||
disabled=not can_correct_subtitles,
|
||||
use_container_width=True,
|
||||
)
|
||||
transcribe_clicked = st.button(
|
||||
tr("Transcribe subtitles"),
|
||||
key="fun_asr_transcribe",
|
||||
disabled=not can_transcribe,
|
||||
use_container_width=True,
|
||||
)
|
||||
|
||||
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(
|
||||
tr("Calibrate subtitles"),
|
||||
key="subtitle_correct",
|
||||
disabled=not can_manage_subtitles,
|
||||
use_container_width=True,
|
||||
)
|
||||
|
||||
target_language = str(target_language or "").strip() or "中文"
|
||||
|
||||
if correct_clicked:
|
||||
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)}")
|
||||
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:
|
||||
return
|
||||
|
||||
@ -1421,6 +1577,7 @@ def render_script_buttons(tr, params):
|
||||
type_option_key = _summary_state_key(summary_config, "type_option")
|
||||
custom_type_key = _summary_state_key(summary_config, "custom_type")
|
||||
original_sound_ratio_key = _summary_state_key(summary_config, "original_sound_ratio")
|
||||
narration_word_count_key = _summary_state_key(summary_config, "narration_word_count")
|
||||
language_option_key = _summary_state_key(summary_config, "narration_language_option")
|
||||
custom_language_key = _summary_state_key(summary_config, "custom_narration_language")
|
||||
narration_copy_key = _summary_state_key(summary_config, "narration_copy")
|
||||
@ -1439,7 +1596,7 @@ def render_script_buttons(tr, params):
|
||||
config_col_widths = [1.15]
|
||||
if show_custom_type:
|
||||
config_col_widths.append(1.15)
|
||||
config_col_widths.extend([0.9, 1.15])
|
||||
config_col_widths.extend([0.9, 0.9, 1.15])
|
||||
if show_custom_language:
|
||||
config_col_widths.append(1.15)
|
||||
|
||||
@ -1470,6 +1627,16 @@ def render_script_buttons(tr, params):
|
||||
key=original_sound_ratio_key,
|
||||
)
|
||||
config_col_index += 1
|
||||
with config_cols[config_col_index]:
|
||||
st.number_input(
|
||||
tr("文案字数"),
|
||||
min_value=MIN_NARRATION_WORD_COUNT,
|
||||
max_value=MAX_NARRATION_WORD_COUNT,
|
||||
value=DEFAULT_NARRATION_WORD_COUNT,
|
||||
step=50,
|
||||
key=narration_word_count_key,
|
||||
)
|
||||
config_col_index += 1
|
||||
with config_cols[config_col_index]:
|
||||
st.selectbox(
|
||||
tr("解说语言"),
|
||||
@ -1510,6 +1677,7 @@ def render_script_buttons(tr, params):
|
||||
type_option_key = _summary_state_key(summary_config, "type_option")
|
||||
custom_type_key = _summary_state_key(summary_config, "custom_type")
|
||||
original_sound_ratio_key = _summary_state_key(summary_config, "original_sound_ratio")
|
||||
narration_word_count_key = _summary_state_key(summary_config, "narration_word_count")
|
||||
language_option_key = _summary_state_key(summary_config, "narration_language_option")
|
||||
custom_language_key = _summary_state_key(summary_config, "custom_narration_language")
|
||||
narration_copy_key = _summary_state_key(summary_config, "narration_copy")
|
||||
@ -1521,6 +1689,9 @@ def render_script_buttons(tr, params):
|
||||
narration_language = _resolve_summary_narration_language(summary_config)
|
||||
drama_genre = _resolve_summary_type(summary_config)
|
||||
original_sound_ratio = int(st.session_state.get(original_sound_ratio_key, 30))
|
||||
narration_word_count = int(
|
||||
st.session_state.get(narration_word_count_key, DEFAULT_NARRATION_WORD_COUNT)
|
||||
)
|
||||
if (
|
||||
st.session_state.get(type_option_key) == "custom"
|
||||
and not str(st.session_state.get(custom_type_key, '') or '').strip()
|
||||
@ -1567,6 +1738,7 @@ def render_script_buttons(tr, params):
|
||||
video_paths=_selected_video_paths(),
|
||||
narration_language=narration_language,
|
||||
drama_genre=drama_genre,
|
||||
narration_word_count=narration_word_count,
|
||||
prompt_category=summary_config["prompt_category"],
|
||||
search_keywords=summary_config["search_keywords"],
|
||||
empty_title_message_key=summary_config["empty_title_message_key"],
|
||||
@ -1616,8 +1788,66 @@ def render_script_buttons(tr, params):
|
||||
generate_script_docu(params, tr)
|
||||
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')
|
||||
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:
|
||||
load_script(tr, script_path)
|
||||
|
||||
|
||||
@ -47,6 +47,13 @@ SUBTITLE_PREVIEW_FALLBACK_SIZES = {
|
||||
"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):
|
||||
"""渲染字幕设置面板"""
|
||||
@ -130,6 +137,25 @@ def _get_current_preview_video_path():
|
||||
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):
|
||||
if uploaded_file is None:
|
||||
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_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(
|
||||
tr("Subtitle Preview Orientation"),
|
||||
options=["portrait", "landscape"],
|
||||
default=st.session_state.get("subtitle_preview_orientation", "portrait"),
|
||||
format_func=lambda value: tr("Portrait Safe Area") if value == "portrait" else tr("Landscape Safe Area"),
|
||||
options=SUBTITLE_PREVIEW_ORIENTATIONS,
|
||||
format_func=lambda value: tr(SUBTITLE_PREVIEW_ORIENTATION_LABELS[value]),
|
||||
key="subtitle_preview_orientation",
|
||||
width="stretch",
|
||||
) or "portrait"
|
||||
) or orientation_default
|
||||
_render_subtitle_position_controls(tr, orientation)
|
||||
|
||||
preview_text = tr("Subtitle Preview Sample Text")
|
||||
|
||||
@ -4,8 +4,54 @@ import shutil
|
||||
from loguru import logger
|
||||
|
||||
from app.config import config
|
||||
from app.services.llm.unified_service import UnifiedLLMService
|
||||
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):
|
||||
@ -188,7 +234,7 @@ def render_ffmpeg_engine_settings(tr):
|
||||
def render_system_panel(tr):
|
||||
"""渲染系统设置面板"""
|
||||
with st.expander(tr("System settings"), expanded=False):
|
||||
col1, col2, col3 = st.columns(3)
|
||||
col1, col2, col3, col4 = st.columns(4)
|
||||
|
||||
with col1:
|
||||
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):
|
||||
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)
|
||||
|
||||
63
webui/components/test_audio_settings_unittest.py
Normal 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()
|
||||
21
webui/components/test_subtitle_settings_unittest.py
Normal 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()
|
||||
46
webui/components/test_system_settings_unittest.py
Normal 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()
|
||||
@ -54,7 +54,21 @@
|
||||
"Custom Background Music": "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 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",
|
||||
"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.",
|
||||
@ -197,6 +211,10 @@
|
||||
"Text API Key": "Text API Key",
|
||||
"Text Base URL": "Text Base URL",
|
||||
"Text Model Name": "Text Model Name",
|
||||
"High Reasoning Model Name": "High-Reasoning Model Name",
|
||||
"High Efficiency Model Name": "High-Efficiency Model Name",
|
||||
"High Reasoning Model Help": "Used for plot analysis, copy generation, and script generation and matching.",
|
||||
"High Efficiency Model Help": "Used for subtitle translation and calibration; falls back to the high-reasoning model when empty.",
|
||||
"Top P": "Top P",
|
||||
"Top K": "Top K",
|
||||
"Max Output Tokens": "Max Output Tokens",
|
||||
@ -333,6 +351,7 @@
|
||||
"影视类型": "Film/TV Type",
|
||||
"自定义影视类型": "Custom Film/TV Type",
|
||||
"原片占比": "Original Footage Ratio",
|
||||
"文案字数": "Copy Length",
|
||||
"例如:豪门虐恋": "For example: billionaire angst romance",
|
||||
"例如:悬疑犯罪": "For example: suspense crime",
|
||||
"请输入自定义短剧类型": "Please enter a custom short drama type",
|
||||
@ -373,13 +392,19 @@
|
||||
"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.",
|
||||
"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 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 download link": "Download link: https://pan.quark.cn/s/0767c9bcefd5",
|
||||
"IndexTTS2 features": "A locally or privately deployed IndexTTS-2 voice-cloning engine with emotion control and fuller 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 API service before use.",
|
||||
"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, 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 packages:\n\n- **macOS**: [Download](https://cutagent.online/resources/indextts15-mlx-macos)\n- **Windows**: [Download](https://cutagent.online/resources/indextts15-windows)",
|
||||
"IndexTTS macOS features": "A local IndexTTS-1.5 MLX voice-cloning engine for Apple Silicon that uploads reference audio to synthesize narration.",
|
||||
"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 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.",
|
||||
"Local Deployment": "Local Deployment",
|
||||
"Cloud Service": "Cloud Service",
|
||||
@ -472,6 +497,7 @@
|
||||
"Characters": "characters",
|
||||
"Ali Bailian Fun-ASR Subtitle Transcription": "Subtitle Processing",
|
||||
"Subtitle Processing Method": "Subtitle Processing Method",
|
||||
"Subtitle transcription package downloads": "**Subtitle transcription package downloads**\n\n- **Fun-ASR-Nano**: [macOS](https://cutagent.online/resources/fun-asr-nano-macos) · [Windows](https://cutagent.online/resources/fun-asr-nano-windows)\n- **FunASR v1.1.0**: [macOS](https://cutagent.online/resources/funasr-v110-macos) · [Windows](https://cutagent.online/resources/funasr-v110-windows)\n- **FireRedASR2-AED**: [macOS](https://cutagent.online/resources/fireredasr2-aed-macos) · [Windows](https://cutagent.online/resources/fireredasr2-aed-windows)",
|
||||
"Fun-ASR Backend": "Fun-ASR Backend",
|
||||
"Local FunASR-Pack API": "FunASR (Local)",
|
||||
"Local FireRedASR API": "FireRedASR2 (Local)",
|
||||
@ -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 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 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 Help": "For example, http://127.0.0.1:7867. A full /asr endpoint URL is also supported.",
|
||||
"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}",
|
||||
"Transcribe subtitles": "Transcribe 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 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",
|
||||
@ -515,6 +544,11 @@
|
||||
"Subtitle calibration succeeded": "Subtitle calibration succeeded: {file}",
|
||||
"Subtitle calibration succeeded for multiple files": "Subtitle calibration succeeded for {count} files: {files}",
|
||||
"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",
|
||||
"Tavily Search Settings": "Tavily Web Search",
|
||||
"Tavily API Key": "Tavily API Key",
|
||||
@ -556,8 +590,30 @@
|
||||
"Select Qwen3 TTS Voice": "Select a Qwen3 TTS voice",
|
||||
"API URL": "API 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.",
|
||||
"VoxCPM API URL Help": "VoxCPM-0.5B-Pack service URL. Enter the service root, /tts, or /v1/audio/speech endpoint.",
|
||||
"VoxCPM Use Reference Audio": "Clone from Reference Audio",
|
||||
"VoxCPM Use Reference Audio Help": "Uploads prompt_audio through /tts when enabled; otherwise uses the model's default voice.",
|
||||
"VoxCPM Prompt Text": "Reference Audio Text",
|
||||
"VoxCPM Prompt Text Help": "Optional transcript of the speech in the reference audio.",
|
||||
"VoxCPM Normalize": "Normalize Text",
|
||||
"VoxCPM Denoise": "Denoise Reference Audio",
|
||||
"VoxCPM Usage Instructions Title": "VoxCPM-0.5B Usage Instructions",
|
||||
"VoxCPM Usage Instructions": "1. Start VoxCPM-0.5B-Pack (double-click start.command on macOS).\n2. The default service URL is http://127.0.0.1:7864.\n3. Use the default voice without reference audio, or enable reference audio to clone a voice.\n4. The first request may take longer while the model loads.",
|
||||
"VoxCPM2 API URL Help": "VoxCPM-2B-Pack service URL. Enter the service root, /tts, /tts/batch, or /v1/audio/speech endpoint.",
|
||||
"VoxCPM2 Generation Mode": "Generation Mode",
|
||||
"VoxCPM2 Generation Mode Help": "Voice design generates a voice from a text description; cloning uploads reference_audio.",
|
||||
"VoxCPM2 Mode Design": "Voice Design",
|
||||
"VoxCPM2 Mode Clone": "Reference Audio Clone",
|
||||
"VoxCPM2 Voice Control": "Voice Description / Style Control",
|
||||
"VoxCPM2 Voice Control Help": "Describe voice, emotion, pace, and expression, for example: warm, natural, slightly fast young female voice.",
|
||||
"VoxCPM2 Output 48k": "Output 48kHz Audio",
|
||||
"VoxCPM2 Context Aware": "Enable Context Awareness",
|
||||
"VoxCPM2 Streaming": "Enable Streaming Inference",
|
||||
"VoxCPM2 Usage Instructions Title": "VoxCPM-2B Usage Instructions",
|
||||
"VoxCPM2 Usage Instructions": "1. Start VoxCPM-2B-Pack (double-click start.command on macOS).\n2. The default service URL is http://127.0.0.1:7863.\n3. Describe the desired voice in design mode, or choose reference audio in clone mode.\n4. Initial model loading and 2B generation may take longer.",
|
||||
"OmniVoice Language Code": "Synthesis Language",
|
||||
"OmniVoice Language Code Help": "The language parameter sent to OmniVoice-Pack, such as zh or en.",
|
||||
"OmniVoice Generation Mode": "Generation Mode",
|
||||
@ -610,7 +666,18 @@
|
||||
"Enable Sampling Help": "Enable sampling for more natural speech.",
|
||||
"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 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": "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.5x–2.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 Help": "Choose the emotion control source for IndexTTS-2.",
|
||||
"Emotion Mode Speaker": "Same as speaker reference",
|
||||
@ -634,16 +701,27 @@
|
||||
"Emotion Melancholic": "Melancholic",
|
||||
"Emotion Surprised": "Surprised",
|
||||
"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 Help": "Maximum text tokens per segment for IndexTTS-2 inference.",
|
||||
"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.",
|
||||
"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": "**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 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 Token Help": "Doubao TTS application Token",
|
||||
"Cluster": "Cluster",
|
||||
@ -656,13 +734,13 @@
|
||||
"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",
|
||||
"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 2": "2. Create a new Access Key and Secret Key",
|
||||
"Doubao TTS Step 3": "3. Open [https://www.volcengine.com/product/voice-tech](https://www.volcengine.com/product/voice-tech)",
|
||||
"Doubao TTS Step 4": "4. Click Start Now",
|
||||
"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 6": "6. Scroll to the bottom to get the APPID and Access Token",
|
||||
"Doubao TTS Fill Credentials Notice": "Fill the Access Key, Secret Key, AppID, and Token above.",
|
||||
"Doubao TTS Step 1": "1. Open the Volcengine Doubao Speech console",
|
||||
"Doubao TTS Step 2": "2. Open API Key management and create an API Key",
|
||||
"Doubao TTS Step 3": "3. Make sure Doubao speech synthesis is enabled",
|
||||
"Doubao TTS Step 4": "4. Copy the API Key into the API Key field above",
|
||||
"Doubao TTS Step 5": "5. The default cluster is volcano_tts and usually does not need changes",
|
||||
"Doubao TTS Step 6": "6. Legacy AppID/Token users can keep using the compatibility fields",
|
||||
"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",
|
||||
"Please configure missing fields": "Please configure: {fields}",
|
||||
"Preview Voice Synthesis": "Preview Voice Synthesis",
|
||||
|
||||
@ -42,7 +42,21 @@
|
||||
"Custom Background Music": "自定义背景音乐",
|
||||
"Custom Background Music File": "请输入自定义背景音乐的文件路径",
|
||||
"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": "上传背景音乐",
|
||||
"Background Music Path Help": "选择用于视频合成的背景音乐",
|
||||
"No Background Music Resources Found": "未找到资源目录中的背景音乐,请上传背景音乐文件",
|
||||
@ -186,6 +200,10 @@
|
||||
"Text API Key": "文案生成 API 密钥",
|
||||
"Text Base URL": "文案生成接口地址",
|
||||
"Text Model Name": "文案生成模型名称",
|
||||
"High Reasoning Model Name": "高推理模型名称",
|
||||
"High Efficiency Model Name": "高效率模型名称",
|
||||
"High Reasoning Model Help": "用于剧情分析、文案生成、脚本生成与匹配等复杂任务。",
|
||||
"High Efficiency Model Help": "用于字幕翻译、字幕校准等批量任务;留空时自动使用高推理模型。",
|
||||
"Top P": "Top P",
|
||||
"Top K": "Top K",
|
||||
"Max Output Tokens": "最大输出 Token",
|
||||
@ -311,13 +329,19 @@
|
||||
"Tencent Cloud TTS use case": "个人和企业用户,需要稳定的中文语音合成",
|
||||
"Tongyi Qwen3 TTS features": "阿里云通义千问语音合成,音质优秀,支持多种音色",
|
||||
"High-quality Chinese speech synthesis use case": "需要高质量中文语音合成的用户",
|
||||
"IndexTTS features": "本地/私有部署的 IndexTTS-1.5 语音克隆引擎。选择资源目录音频或上传参考音频后,可按该音色合成旁白。",
|
||||
"IndexTTS use case": "适合需要固定旁白音色、角色配音或批量生成同一音色视频的场景。使用前请先启动 IndexTTS-1.5 API 服务;部署包下载:https://pan.quark.cn/s/0767c9bcefd5",
|
||||
"IndexTTS download link": "下载地址:https://pan.quark.cn/s/0767c9bcefd5",
|
||||
"IndexTTS2 features": "本地/私有部署的 IndexTTS-2 语音克隆引擎,支持情感控制和更完整的生成参数。",
|
||||
"IndexTTS2 use case": "适合需要固定音色、情绪化旁白或更细致采样控制的本地语音合成场景。使用前请先启动 IndexTTS-2 API 服务。",
|
||||
"IndexTTS features": "Windows 本地/私有部署的 IndexTTS-1.5 语音克隆引擎。选择资源目录音频或上传参考音频后,可按该音色合成旁白。",
|
||||
"IndexTTS use case": "适合在 Windows 上生成固定旁白音色、角色配音或批量生成同一音色视频。使用前请先启动 IndexTTS-1.5 API 服务;[下载部署包](https://cutagent.online/resources/indextts15-windows)。",
|
||||
"IndexTTS download link": "下载地址:\n\n- **macOS**:[下载地址](https://cutagent.online/resources/indextts15-mlx-macos)\n- **Windows**:[下载地址](https://cutagent.online/resources/indextts15-windows)",
|
||||
"IndexTTS macOS features": "面向 Apple Silicon 的 IndexTTS-1.5 MLX 本地语音克隆引擎,通过上传参考音频合成旁白。",
|
||||
"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 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": "火山引擎豆包语音合成,支持多种音色和情感,国内访问速度快",
|
||||
"Local Deployment": "本地部署",
|
||||
"Cloud Service": "云端服务",
|
||||
@ -411,6 +435,7 @@
|
||||
"Characters": "字符",
|
||||
"Ali Bailian Fun-ASR Subtitle Transcription": "字幕处理",
|
||||
"Subtitle Processing Method": "字幕处理方式",
|
||||
"Subtitle transcription package downloads": "**字幕转录整合包下载**\n\n- **Fun-ASR-Nano**:[macOS](https://cutagent.online/resources/fun-asr-nano-macos) · [Windows](https://cutagent.online/resources/fun-asr-nano-windows)\n- **FunASR v1.1.0**:[macOS](https://cutagent.online/resources/funasr-v110-macos) · [Windows](https://cutagent.online/resources/funasr-v110-windows)\n- **FireRedASR2-AED**:[macOS](https://cutagent.online/resources/fireredasr2-aed-macos) · [Windows](https://cutagent.online/resources/fireredasr2-aed-windows)",
|
||||
"Fun-ASR Backend": "Fun-ASR 后端",
|
||||
"Local FunASR-Pack API": "FunASR(本地部署)",
|
||||
"Local FireRedASR API": "FireRedASR2(本地部署)",
|
||||
@ -422,7 +447,7 @@
|
||||
"Auto Transcription FireRed Caption": "将在最终视频合并完成后,通过本机运行的 FireRedASR2-AED-Pack API 生成 SRT 字幕。",
|
||||
"Auto Transcription Online Caption": "将在最终视频合并完成后,自动上传到阿里百炼临时存储并通过 fun-asr 生成 SRT 字幕。",
|
||||
"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 Help": "例如 http://127.0.0.1:7867;也可以直接填到 /asr 的完整地址。",
|
||||
"Fun-ASR Hotword": "热词",
|
||||
@ -440,6 +465,9 @@
|
||||
"Selected video files do not exist": "以下视频文件不存在,请重新选择或上传: {files}",
|
||||
"Transcribe subtitles": "转录字幕",
|
||||
"Calibrate subtitles": "校准字幕",
|
||||
"Translate subtitles": "翻译字幕",
|
||||
"Subtitle target language": "目标语言",
|
||||
"Subtitle target language placeholder": "中文",
|
||||
"Please enter Ali Bailian API Key": "请先输入阿里百炼 API Key",
|
||||
"Please enter local FunASR-Pack API URL": "请先输入本地 FunASR-Pack API 地址",
|
||||
"Please enter local FireRedASR API URL": "请先输入本地ASR API 地址",
|
||||
@ -454,6 +482,11 @@
|
||||
"Subtitle calibration succeeded": "字幕校准成功: {file}",
|
||||
"Subtitle calibration succeeded for multiple files": "字幕校准成功,共 {count} 个文件: {files}",
|
||||
"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},可从该目录拖入上传",
|
||||
"Tavily Search Settings": "Tavily 联网搜索",
|
||||
"Tavily API Key": "Tavily API Key",
|
||||
@ -495,8 +528,30 @@
|
||||
"Select Qwen3 TTS Voice": "选择 Qwen3 TTS 音色",
|
||||
"API URL": "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 地址",
|
||||
"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-Pack(macOS 双击 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-Pack(macOS 双击 start.command)。\n2. 默认服务地址为 http://127.0.0.1:7863。\n3. 音色设计模式填写声音与风格描述;克隆模式选择参考音频,可补充其文本。\n4. 2B 模型首次加载和生成可能耗时较长。",
|
||||
"OmniVoice Language Code": "合成语言",
|
||||
"OmniVoice Language Code Help": "传给 OmniVoice-Pack 的 language 参数,例如 zh、en。",
|
||||
"OmniVoice Generation Mode": "生成模式",
|
||||
@ -549,7 +604,18 @@
|
||||
"Enable Sampling Help": "启用采样可以获得更自然的语音",
|
||||
"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 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": "情感覆盖",
|
||||
"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.5x–2.0x)。",
|
||||
"IndexTTS2 Seed": "随机种子",
|
||||
"IndexTTS2 Seed Help": "可选整数,用于复现生成结果;留空则随机采样。",
|
||||
"IndexTTS2 Seed Placeholder": "随机",
|
||||
"Emotion Mode": "情感控制方式",
|
||||
"Emotion Mode Help": "选择 IndexTTS-2 的情感控制来源",
|
||||
"Emotion Mode Speaker": "与音色参考相同",
|
||||
@ -573,16 +639,27 @@
|
||||
"Emotion Melancholic": "忧郁",
|
||||
"Emotion Surprised": "惊讶",
|
||||
"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 Help": "IndexTTS-2 分段推理的最大文本 token 数",
|
||||
"Max Mel Tokens": "最大 Mel Tokens",
|
||||
"Max Mel Tokens Help": "控制单次生成的最大 mel token 数,值越大可生成更长音频",
|
||||
"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:7860;NarratoAI 会调用 /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": "**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 Secret Key Help": "火山引擎 Secret Key",
|
||||
"Doubao API Key Help": "新版豆包语音 API Key;优先使用该字段,无需填写 AppID 和 Token",
|
||||
"Doubao Legacy Credentials": "旧版 AppID / Token 配置(兼容)",
|
||||
"Doubao AppID Help": "豆包语音应用 AppID",
|
||||
"Doubao Token Help": "豆包语音应用 Token",
|
||||
"Cluster": "集群",
|
||||
@ -595,13 +672,13 @@
|
||||
"Sentence Silence Duration Help": "调节句尾静音时长 (0.0-2.0 秒)",
|
||||
"Doubao TTS API Key Application Process": "豆包语音 TTS API Key申请流程",
|
||||
"Application Steps": "申请步骤",
|
||||
"Doubao TTS Step 1": "1. 打开 [https://console.volcengine.com/iam/keymanage](https://console.volcengine.com/iam/keymanage)",
|
||||
"Doubao TTS Step 2": "2. 新建 Access Key 和 Secret Key",
|
||||
"Doubao TTS Step 3": "3. 打开 [https://www.volcengine.com/product/voice-tech](https://www.volcengine.com/product/voice-tech)",
|
||||
"Doubao TTS Step 4": "4. 点击立即使用",
|
||||
"Doubao TTS Step 5": "5. 在最左边的 API 服务中心找到音频生成下面的语音合成(注意:是语音合成,不是语音合成大模型)",
|
||||
"Doubao TTS Step 6": "6. 翻到最下面获取 APPID 和 Access Token",
|
||||
"Doubao TTS Fill Credentials Notice": "请将获取到的 Access Key、Secret Key、AppID 和 Token 填写到上方的配置中",
|
||||
"Doubao TTS Step 1": "1. 打开火山引擎豆包语音控制台",
|
||||
"Doubao TTS Step 2": "2. 进入 API Key 管理并创建 API Key",
|
||||
"Doubao TTS Step 3": "3. 确认已开通豆包语音合成服务",
|
||||
"Doubao TTS Step 4": "4. 复制 API Key 并填写到上方 API Key 输入框",
|
||||
"Doubao TTS Step 5": "5. 默认集群使用 volcano_tts,通常无需修改",
|
||||
"Doubao TTS Step 6": "6. 旧版 AppID/Token 用户可继续在兼容配置中填写原凭据",
|
||||
"Doubao TTS Fill Credentials Notice": "新版配置只需要填写 API Key;旧版 AppID/Token 仍保留兼容",
|
||||
"Doubao TTS configured": "豆包语音 TTS 配置已设置",
|
||||
"Please configure missing fields": "请配置: {fields}",
|
||||
"Preview Voice Synthesis": "试听语音合成",
|
||||
@ -636,6 +713,7 @@
|
||||
"影视类型": "影视类型",
|
||||
"自定义影视类型": "自定义影视类型",
|
||||
"原片占比": "原片占比",
|
||||
"文案字数": "文案字数",
|
||||
"例如:豪门虐恋": "例如:豪门虐恋",
|
||||
"例如:悬疑犯罪": "例如:悬疑犯罪",
|
||||
"请输入自定义短剧类型": "请输入自定义短剧类型",
|
||||
|
||||
@ -8,9 +8,48 @@ from loguru import logger
|
||||
from app.config import config
|
||||
from app.services.upload_validation import ensure_existing_file, InputValidationError
|
||||
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: 翻译函数
|
||||
params: 视频参数对象
|
||||
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)
|
||||
status_text = st.empty()
|
||||
@ -33,38 +80,47 @@ def generate_script_short(tr, params, custom_clips=5):
|
||||
with st.spinner(tr("Generating script...")):
|
||||
# ========== 严格验证:必须上传视频和字幕(与短剧解说保持一致)==========
|
||||
# 1. 验证视频文件
|
||||
video_path = getattr(params, "video_origin_path", None)
|
||||
if not video_path or not str(video_path).strip():
|
||||
selected_video_paths = _normalize_paths(
|
||||
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.stop()
|
||||
|
||||
try:
|
||||
ensure_existing_file(
|
||||
str(video_path),
|
||||
label=tr("Video"),
|
||||
allowed_exts=(".mp4", ".mov", ".avi", ".flv", ".mkv"),
|
||||
)
|
||||
except InputValidationError as e:
|
||||
st.error(str(e))
|
||||
st.stop()
|
||||
for video_path in selected_video_paths:
|
||||
try:
|
||||
ensure_existing_file(
|
||||
str(video_path),
|
||||
label=tr("Video"),
|
||||
allowed_exts=(".mp4", ".mov", ".avi", ".flv", ".mkv"),
|
||||
)
|
||||
except InputValidationError as e:
|
||||
st.error(str(e))
|
||||
st.stop()
|
||||
|
||||
# 2. 验证字幕文件(移除推断逻辑,必须上传)
|
||||
subtitle_path = st.session_state.get("subtitle_path")
|
||||
if not subtitle_path or not str(subtitle_path).strip():
|
||||
subtitle_paths = _normalize_paths(subtitle_paths or st.session_state.get("subtitle_paths") or st.session_state.get("subtitle_path"))
|
||||
if not subtitle_paths:
|
||||
st.error(tr("Please upload subtitle file first"))
|
||||
st.stop()
|
||||
|
||||
validated_subtitle_paths = []
|
||||
try:
|
||||
subtitle_path = ensure_existing_file(
|
||||
str(subtitle_path),
|
||||
label=tr("Subtitle"),
|
||||
allowed_exts=(".srt",),
|
||||
)
|
||||
for subtitle_path in subtitle_paths:
|
||||
validated_subtitle_paths.append(
|
||||
ensure_existing_file(
|
||||
str(subtitle_path),
|
||||
label=tr("Subtitle"),
|
||||
allowed_exts=(".srt",),
|
||||
)
|
||||
)
|
||||
except InputValidationError as e:
|
||||
st.error(str(e))
|
||||
st.stop()
|
||||
|
||||
logger.info(f"使用用户上传的字幕文件: {subtitle_path}")
|
||||
logger.info(f"使用用户处理后的字幕文件: {validated_subtitle_paths}")
|
||||
|
||||
# ========== 获取 LLM 配置 ==========
|
||||
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"))
|
||||
|
||||
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
|
||||
|
||||
output_path = os.path.join(utils.script_dir(), "merged_subtitle.json")
|
||||
|
||||
subtitle_content = st.session_state.get("subtitle_content")
|
||||
subtitle_kwargs = (
|
||||
{"subtitle_content": str(subtitle_content)}
|
||||
if subtitle_content is not None and str(subtitle_content).strip()
|
||||
else {"subtitle_file_path": subtitle_path}
|
||||
)
|
||||
|
||||
update_progress(55, tr("Generating script..."))
|
||||
result = generate_script_result(
|
||||
api_key=text_api_key,
|
||||
model_name=text_model,
|
||||
@ -99,7 +177,11 @@ def generate_script_short(tr, params, custom_clips=5):
|
||||
base_url=text_base_url,
|
||||
custom_clips=custom_clips,
|
||||
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":
|
||||
@ -109,10 +191,7 @@ def generate_script_short(tr, params, custom_clips=5):
|
||||
script = result.get("script")
|
||||
logger.info(f"脚本生成完成 {json.dumps(script, ensure_ascii=False, indent=4)}")
|
||||
|
||||
if isinstance(script, list):
|
||||
st.session_state['video_clip_json'] = script
|
||||
elif isinstance(script, str):
|
||||
st.session_state['video_clip_json'] = json.loads(script)
|
||||
st.session_state['video_clip_json'] = _parse_generated_script_payload(script)
|
||||
|
||||
update_progress(80, tr("Script generation completed"))
|
||||
|
||||
@ -120,8 +199,14 @@ def generate_script_short(tr, params, custom_clips=5):
|
||||
progress_bar.progress(100)
|
||||
status_text.text(tr("Script generation completed!"))
|
||||
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:
|
||||
progress_bar.progress(100)
|
||||
st.error(f"{tr('Generation error')}: {str(err)}")
|
||||
logger.exception(f"生成脚本时发生错误\n{traceback.format_exc()}")
|
||||
return None
|
||||
|
||||
@ -176,8 +176,8 @@ def parse_and_fix_json(json_string):
|
||||
# 5. 修复单引号
|
||||
fixed_json = re.sub(r"'([^']*)':", r'"\1":', fixed_json)
|
||||
|
||||
# 6. 修复没有引号的属性名
|
||||
fixed_json = re.sub(r'(\w+)(\s*):', r'"\1"\2:', fixed_json)
|
||||
# 6. 修复没有引号的属性名,仅匹配对象边界后的 key,避免误伤时间戳等字符串值
|
||||
fixed_json = re.sub(r'([{\[,]\s*)([A-Za-z_][A-Za-z0-9_]*)(\s*:)', r'\1"\2"\3', fixed_json)
|
||||
|
||||
# 7. 修复重复的引号
|
||||
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,
|
||||
empty_title_message_key: str = "Please enter short drama name before web search",
|
||||
web_search_context_description: str = "短剧名称、人物关系、剧情背景和公开剧情梗概",
|
||||
narration_word_count: int = 500,
|
||||
):
|
||||
"""生成可由用户审核修改的短剧解说正文,不绑定时间戳。"""
|
||||
subtitle_paths = _normalize_paths(subtitle_path)
|
||||
@ -422,6 +423,7 @@ def generate_short_drama_narration_copy(
|
||||
temperature=temperature,
|
||||
narration_language=narration_language,
|
||||
drama_genre=drama_genre,
|
||||
narration_word_count=narration_word_count,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"使用新LLM服务生成文案失败,回退到旧实现: {str(e)}")
|
||||
@ -436,6 +438,7 @@ def generate_short_drama_narration_copy(
|
||||
provider=text_provider,
|
||||
narration_language=narration_language,
|
||||
drama_genre=drama_genre,
|
||||
narration_word_count=narration_word_count,
|
||||
prompt_category=prompt_category,
|
||||
)
|
||||
|
||||
|
||||
38
webui/tools/test_generate_script_short_unittest.py
Normal 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()
|
||||
@ -22,6 +22,19 @@ class GenerateShortSummaryJsonTests(unittest.TestCase):
|
||||
|
||||
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__":
|
||||
unittest.main()
|
||||
|
||||