Spaces:
Running
Running
Dee Ferdinand commited on
Commit ·
ffa5738
1
Parent(s): 127b12a
feat: deploy MoneyPrinterTurbo swarm with zero binary files to bypass HF spaces binary hooks
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .dockerignore +24 -0
- .github/ISSUE_TEMPLATE/bug_report.yml +87 -0
- .github/ISSUE_TEMPLATE/config.yml +1 -0
- .github/ISSUE_TEMPLATE/feature_request.yml +29 -0
- .github/SECURITY.md +28 -0
- .gitignore +36 -0
- .python-version +1 -0
- Dockerfile +44 -0
- Dockerfile.gpu +55 -0
- LICENSE +21 -0
- README-ar.md +416 -0
- README-en.md +433 -0
- README.md +414 -7
- app/__init__.py +0 -0
- app/asgi.py +82 -0
- app/config/__init__.py +56 -0
- app/config/config.py +207 -0
- app/controllers/base.py +31 -0
- app/controllers/manager/base_manager.py +87 -0
- app/controllers/manager/memory_manager.py +21 -0
- app/controllers/manager/redis_manager.py +64 -0
- app/controllers/ping.py +13 -0
- app/controllers/v1/base.py +11 -0
- app/controllers/v1/llm.py +47 -0
- app/controllers/v1/video.py +400 -0
- app/models/__init__.py +0 -0
- app/models/const.py +30 -0
- app/models/exception.py +28 -0
- app/models/schema.py +344 -0
- app/router.py +17 -0
- app/services/__init__.py +0 -0
- app/services/data/azure_voices.json +1326 -0
- app/services/llm.py +725 -0
- app/services/material.py +388 -0
- app/services/state.py +168 -0
- app/services/subtitle.py +306 -0
- app/services/task.py +397 -0
- app/services/upload_post.py +147 -0
- app/services/utils/video_effects.py +73 -0
- app/services/video.py +928 -0
- app/services/voice.py +1400 -0
- app/utils/file_security.py +35 -0
- app/utils/utils.py +274 -0
- config.example.toml +325 -0
- docker-compose.gpu.yml +27 -0
- docker-compose.yml +24 -0
- main.py +16 -0
- pyproject.toml +46 -0
- requirements.txt +20 -0
- resource/public/assets/index-CRiTZ9sN.css +1 -0
.dockerignore
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Exclude common Python files and directories
|
| 2 |
+
venv/
|
| 3 |
+
__pycache__/
|
| 4 |
+
*.pyc
|
| 5 |
+
*.pyo
|
| 6 |
+
*.pyd
|
| 7 |
+
*.pyz
|
| 8 |
+
*.pyw
|
| 9 |
+
*.pyi
|
| 10 |
+
*.egg-info/
|
| 11 |
+
|
| 12 |
+
# Exclude development and local files
|
| 13 |
+
.env
|
| 14 |
+
.env.*
|
| 15 |
+
*.log
|
| 16 |
+
*.db
|
| 17 |
+
|
| 18 |
+
# Exclude version control system files
|
| 19 |
+
.git/
|
| 20 |
+
.gitignore
|
| 21 |
+
.svn/
|
| 22 |
+
|
| 23 |
+
storage/
|
| 24 |
+
config.toml
|
.github/ISSUE_TEMPLATE/bug_report.yml
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: 🐛 Bug | Bug Report
|
| 2 |
+
description: 报告错误或异常问题 | Report an error or unexpected behavior
|
| 3 |
+
title: "[Bug]: "
|
| 4 |
+
labels:
|
| 5 |
+
- bug
|
| 6 |
+
|
| 7 |
+
body:
|
| 8 |
+
- type: markdown
|
| 9 |
+
attributes:
|
| 10 |
+
value: |
|
| 11 |
+
**提交问题前,请确保您已阅读以下文档:[Getting Started (English)](https://github.com/harry0703/MoneyPrinterTurbo/blob/main/README-en.md#system-requirements-) 或 [快速开始 (中文)](https://github.com/harry0703/MoneyPrinterTurbo/blob/main/README.md#%E5%BF%AB%E9%80%9F%E5%BC%80%E5%A7%8B-)。**
|
| 12 |
+
|
| 13 |
+
**Before submitting an issue, please make sure you've read the following documentation: [Getting Started (English)](https://github.com/harry0703/MoneyPrinterTurbo/blob/main/README-en.md#system-requirements-) or [快速开始 (Chinese)](https://github.com/harry0703/MoneyPrinterTurbo/blob/main/README.md#%E5%BF%AB%E9%80%9F%E5%BC%80%E5%A7%8B-).**
|
| 14 |
+
|
| 15 |
+
- type: textarea
|
| 16 |
+
attributes:
|
| 17 |
+
label: 问题描述 | Current Behavior
|
| 18 |
+
description: |
|
| 19 |
+
描述您遇到的问题
|
| 20 |
+
Describe the issue you're experiencing
|
| 21 |
+
placeholder: |
|
| 22 |
+
当我执行...操作时,程序出现了...问题
|
| 23 |
+
When I perform..., the program shows...
|
| 24 |
+
validations:
|
| 25 |
+
required: true
|
| 26 |
+
- type: textarea
|
| 27 |
+
attributes:
|
| 28 |
+
label: 重现步骤 | Steps to Reproduce
|
| 29 |
+
description: |
|
| 30 |
+
详细描述如何重现此问题
|
| 31 |
+
Describe in detail how to reproduce this issue
|
| 32 |
+
placeholder: |
|
| 33 |
+
1. 打开...
|
| 34 |
+
2. 点击...
|
| 35 |
+
3. 出现错误...
|
| 36 |
+
|
| 37 |
+
1. Open...
|
| 38 |
+
2. Click on...
|
| 39 |
+
3. Error occurs...
|
| 40 |
+
validations:
|
| 41 |
+
required: true
|
| 42 |
+
- type: textarea
|
| 43 |
+
attributes:
|
| 44 |
+
label: 错误日志 | Error Logs
|
| 45 |
+
description: |
|
| 46 |
+
请提供相关错误信息或日志(注意不要包含敏感信息)
|
| 47 |
+
Please provide any error messages or logs (be careful not to include sensitive information)
|
| 48 |
+
placeholder: |
|
| 49 |
+
错误信息、日志或截图...
|
| 50 |
+
Error messages, logs, or screenshots...
|
| 51 |
+
validations:
|
| 52 |
+
required: true
|
| 53 |
+
- type: input
|
| 54 |
+
attributes:
|
| 55 |
+
label: Python 版本 | Python Version
|
| 56 |
+
description: |
|
| 57 |
+
您使用的 Python 版本
|
| 58 |
+
The Python version you're using
|
| 59 |
+
placeholder: v3.13.0, v3.10.0, etc.
|
| 60 |
+
validations:
|
| 61 |
+
required: true
|
| 62 |
+
- type: input
|
| 63 |
+
attributes:
|
| 64 |
+
label: 操作系统 | Operating System
|
| 65 |
+
description: |
|
| 66 |
+
您的操作系统信息
|
| 67 |
+
Your operating system information
|
| 68 |
+
placeholder: macOS 14.1, Windows 11, Ubuntu 22.04, etc.
|
| 69 |
+
validations:
|
| 70 |
+
required: true
|
| 71 |
+
- type: input
|
| 72 |
+
attributes:
|
| 73 |
+
label: MoneyPrinterTurbo 版本 | Version
|
| 74 |
+
description: |
|
| 75 |
+
您使用的 MoneyPrinterTurbo 版本
|
| 76 |
+
The version of MoneyPrinterTurbo you're using
|
| 77 |
+
placeholder: v1.2.2, etc.
|
| 78 |
+
validations:
|
| 79 |
+
required: true
|
| 80 |
+
- type: textarea
|
| 81 |
+
attributes:
|
| 82 |
+
label: 补充信息 | Additional Information
|
| 83 |
+
description: |
|
| 84 |
+
其他对解决问题有帮助的信息(如截图、视频等)
|
| 85 |
+
Any other information that might help solve the issue (screenshots, videos, etc.)
|
| 86 |
+
validations:
|
| 87 |
+
required: false
|
.github/ISSUE_TEMPLATE/config.yml
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
blank_issues_enabled: false
|
.github/ISSUE_TEMPLATE/feature_request.yml
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: ✨ 增加功能 | Feature Request
|
| 2 |
+
description: 为此项目提出一个新想法或建议 | Suggest a new idea for this project
|
| 3 |
+
title: "[Feature]: "
|
| 4 |
+
labels:
|
| 5 |
+
- enhancement
|
| 6 |
+
|
| 7 |
+
body:
|
| 8 |
+
- type: textarea
|
| 9 |
+
attributes:
|
| 10 |
+
label: 需求描述 | Problem Statement
|
| 11 |
+
description: |
|
| 12 |
+
请描述您希望解决的问题或需求
|
| 13 |
+
Please describe the problem you want to solve
|
| 14 |
+
placeholder: |
|
| 15 |
+
我在使用过程中遇到了...
|
| 16 |
+
I encountered... when using this project
|
| 17 |
+
validations:
|
| 18 |
+
required: true
|
| 19 |
+
- type: textarea
|
| 20 |
+
attributes:
|
| 21 |
+
label: 建议的解决方案 | Proposed Solution
|
| 22 |
+
description: |
|
| 23 |
+
请描述您认为可行的解决方案或实现方式
|
| 24 |
+
Please describe your suggested solution or implementation
|
| 25 |
+
placeholder: |
|
| 26 |
+
可以考虑添加...功能来解决这个问题
|
| 27 |
+
Consider adding... feature to address this issue
|
| 28 |
+
validations:
|
| 29 |
+
required: true
|
.github/SECURITY.md
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Security Policy
|
| 2 |
+
|
| 3 |
+
## Supported Versions
|
| 4 |
+
|
| 5 |
+
Security fixes are applied on a best-effort basis to the latest `main` branch and the most recent published release line.
|
| 6 |
+
|
| 7 |
+
## Reporting a Vulnerability
|
| 8 |
+
|
| 9 |
+
Please do **not** disclose suspected vulnerabilities in public GitHub issues.
|
| 10 |
+
|
| 11 |
+
Preferred process:
|
| 12 |
+
|
| 13 |
+
1. Use GitHub private vulnerability reporting for this repository if it is available in the repository security settings.
|
| 14 |
+
2. If private reporting is not available, open a minimal public issue that only requests a private contact channel and does **not** include vulnerability details, proof-of-concept code, payloads, or sensitive file paths.
|
| 15 |
+
3. Wait for a maintainer response before sharing any technical details publicly.
|
| 16 |
+
|
| 17 |
+
When reporting a vulnerability privately, include:
|
| 18 |
+
|
| 19 |
+
- affected commit, tag, or release version
|
| 20 |
+
- attack surface or vulnerable endpoint
|
| 21 |
+
- impact summary
|
| 22 |
+
- reproduction conditions
|
| 23 |
+
- suggested remediation, if available
|
| 24 |
+
|
| 25 |
+
## Disclosure Expectations
|
| 26 |
+
|
| 27 |
+
- Please give maintainers reasonable time to investigate and prepare a fix before public disclosure.
|
| 28 |
+
- Once a fix is available, coordinated public disclosure is welcome.
|
.gitignore
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.DS_Store
|
| 2 |
+
/config.toml
|
| 3 |
+
/storage/
|
| 4 |
+
/.idea/
|
| 5 |
+
/app/services/__pycache__
|
| 6 |
+
/app/__pycache__/
|
| 7 |
+
/app/config/__pycache__/
|
| 8 |
+
/app/models/__pycache__/
|
| 9 |
+
/app/utils/__pycache__/
|
| 10 |
+
/*/__pycache__/*
|
| 11 |
+
.vscode
|
| 12 |
+
/**/.streamlit
|
| 13 |
+
__pycache__
|
| 14 |
+
logs/
|
| 15 |
+
|
| 16 |
+
node_modules
|
| 17 |
+
# VuePress 默认临时文件目录
|
| 18 |
+
/sites/docs/.vuepress/.temp
|
| 19 |
+
# VuePress 默认缓存目录
|
| 20 |
+
/sites/docs/.vuepress/.cache
|
| 21 |
+
# VuePress 默认构建生成的静态文件目录
|
| 22 |
+
/sites/docs/.vuepress/dist
|
| 23 |
+
# 模型目录
|
| 24 |
+
/models/
|
| 25 |
+
./models/*
|
| 26 |
+
|
| 27 |
+
venv/
|
| 28 |
+
.venv
|
| 29 |
+
|
| 30 |
+
# Debug and test files
|
| 31 |
+
CLAUDE.md
|
| 32 |
+
debug/
|
| 33 |
+
debug_*.py
|
| 34 |
+
test_*.py
|
| 35 |
+
!test/services/test_*.py
|
| 36 |
+
streamlit.log
|
.python-version
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
3.11
|
Dockerfile
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.10-slim
|
| 2 |
+
|
| 3 |
+
# Set environment variables
|
| 4 |
+
ENV PYTHONUNBUFFERED=1 \
|
| 5 |
+
PYTHONDONTWRITEBYTECODE=1 \
|
| 6 |
+
PORT=7860
|
| 7 |
+
|
| 8 |
+
# Install system dependencies
|
| 9 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 10 |
+
ffmpeg \
|
| 11 |
+
imagemagick \
|
| 12 |
+
curl \
|
| 13 |
+
git \
|
| 14 |
+
build-essential \
|
| 15 |
+
fonts-wqy-zenhei \
|
| 16 |
+
&& apt-get clean \
|
| 17 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 18 |
+
|
| 19 |
+
# Fix ImageMagick policy to allow MoviePy to render subtitles and text clips
|
| 20 |
+
RUN sed -i 's/rights="none" pattern="@\*"/rights="read|write" pattern="@*"/' /etc/ImageMagick-6/policy.xml || true
|
| 21 |
+
|
| 22 |
+
# Set up working directory
|
| 23 |
+
WORKDIR /app
|
| 24 |
+
|
| 25 |
+
# Copy requirements and install
|
| 26 |
+
COPY requirements.txt /app/requirements.txt
|
| 27 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 28 |
+
|
| 29 |
+
# Copy the rest of the application files (including pre-built static assets in resource/public)
|
| 30 |
+
COPY . /app
|
| 31 |
+
|
| 32 |
+
# Create necessary directories and ensure correct permissions
|
| 33 |
+
RUN mkdir -p /app/storage/tasks /app/storage/cache_videos /app/resource/public /app/resource/songs /app/resource/fonts && \
|
| 34 |
+
ln -s /usr/share/fonts/truetype/wqy/wqy-zenhei.ttc /app/resource/fonts/STHeitiMedium.ttc && \
|
| 35 |
+
ln -s /usr/share/fonts/truetype/wqy/wqy-zenhei.ttc /app/resource/fonts/STHeitiLight.ttc && \
|
| 36 |
+
ln -s /usr/share/fonts/truetype/wqy/wqy-zenhei.ttc /app/resource/fonts/MicrosoftYaHeiNormal.ttc && \
|
| 37 |
+
ln -s /usr/share/fonts/truetype/wqy/wqy-zenhei.ttc /app/resource/fonts/MicrosoftYaHeiBold.ttc && \
|
| 38 |
+
chmod -R 777 /app/storage /app/resource
|
| 39 |
+
|
| 40 |
+
# Expose port
|
| 41 |
+
EXPOSE 7860
|
| 42 |
+
|
| 43 |
+
# Command to run the application
|
| 44 |
+
CMD ["python", "main.py"]
|
Dockerfile.gpu
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Use NVIDIA CUDA runtime as parent image (includes CUDA 12.1 + cuDNN 8)
|
| 2 |
+
FROM nvidia/cuda:12.1.1-cudnn8-runtime-ubuntu22.04
|
| 3 |
+
|
| 4 |
+
# Avoid interactive timezone prompt
|
| 5 |
+
ENV DEBIAN_FRONTEND=noninteractive
|
| 6 |
+
|
| 7 |
+
# Set the working directory in the container
|
| 8 |
+
WORKDIR /MoneyPrinterTurbo
|
| 9 |
+
RUN chmod 777 /MoneyPrinterTurbo
|
| 10 |
+
|
| 11 |
+
ENV PYTHONPATH="/MoneyPrinterTurbo"
|
| 12 |
+
|
| 13 |
+
# Install Python 3.11 and system dependencies
|
| 14 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 15 |
+
software-properties-common \
|
| 16 |
+
git \
|
| 17 |
+
imagemagick \
|
| 18 |
+
ffmpeg \
|
| 19 |
+
curl \
|
| 20 |
+
&& add-apt-repository ppa:deadsnakes/ppa \
|
| 21 |
+
&& apt-get update && apt-get install -y --no-install-recommends \
|
| 22 |
+
python3.11 \
|
| 23 |
+
python3.11-venv \
|
| 24 |
+
python3.11-dev \
|
| 25 |
+
python3-pip \
|
| 26 |
+
&& ln -sf /usr/bin/python3.11 /usr/bin/python3 \
|
| 27 |
+
&& ln -sf /usr/bin/python3.11 /usr/bin/python \
|
| 28 |
+
&& python3.11 -m pip install --upgrade pip \
|
| 29 |
+
&& apt-get remove -y python3-blinker || true \
|
| 30 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 31 |
+
|
| 32 |
+
# Fix security policy for ImageMagick
|
| 33 |
+
RUN sed -i '/<policy domain="path" rights="none" pattern="@\*"/d' /etc/ImageMagick-6/policy.xml
|
| 34 |
+
|
| 35 |
+
# Copy only the requirements.txt first to leverage Docker cache
|
| 36 |
+
COPY requirements.txt ./
|
| 37 |
+
|
| 38 |
+
# Install Python dependencies
|
| 39 |
+
RUN python3 -m pip install --no-cache-dir \
|
| 40 |
+
-i https://mirrors.aliyun.com/pypi/simple/ --trusted-host mirrors.aliyun.com \
|
| 41 |
+
--retries 3 --timeout 60 -r requirements.txt || \
|
| 42 |
+
python3 -m pip install --no-cache-dir \
|
| 43 |
+
-i https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple/ --trusted-host mirrors.tuna.tsinghua.edu.cn \
|
| 44 |
+
--retries 3 --timeout 60 -r requirements.txt || \
|
| 45 |
+
python3 -m pip install --no-cache-dir \
|
| 46 |
+
--retries 3 --timeout 60 -r requirements.txt
|
| 47 |
+
|
| 48 |
+
# Now copy the rest of the codebase into the image
|
| 49 |
+
COPY . .
|
| 50 |
+
|
| 51 |
+
# Expose the port the app runs on
|
| 52 |
+
EXPOSE 8501
|
| 53 |
+
|
| 54 |
+
# Command to run the application
|
| 55 |
+
CMD ["streamlit", "run", "./webui/Main.py","--browser.serverAddress=127.0.0.1","--server.enableCORS=True","--browser.gatherUsageStats=False"]
|
LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
MIT License
|
| 2 |
+
|
| 3 |
+
Copyright (c) 2024 Harry
|
| 4 |
+
|
| 5 |
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
| 6 |
+
of this software and associated documentation files (the "Software"), to deal
|
| 7 |
+
in the Software without restriction, including without limitation the rights
|
| 8 |
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
| 9 |
+
copies of the Software, and to permit persons to whom the Software is
|
| 10 |
+
furnished to do so, subject to the following conditions:
|
| 11 |
+
|
| 12 |
+
The above copyright notice and this permission notice shall be included in all
|
| 13 |
+
copies or substantial portions of the Software.
|
| 14 |
+
|
| 15 |
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
| 16 |
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
| 17 |
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
| 18 |
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
| 19 |
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
| 20 |
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
| 21 |
+
SOFTWARE.
|
README-ar.md
ADDED
|
@@ -0,0 +1,416 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<div align="center">
|
| 2 |
+
<h1 align="center">MoneyPrinterTurbo 💸</h1>
|
| 3 |
+
|
| 4 |
+
<p align="center">
|
| 5 |
+
<a href="https://github.com/harry0703/MoneyPrinterTurbo/stargazers"><img src="https://img.shields.io/github/stars/harry0703/MoneyPrinterTurbo.svg?style=for-the-badge" alt="Stargazers"></a>
|
| 6 |
+
<a href="https://github.com/harry0703/MoneyPrinterTurbo/issues"><img src="https://img.shields.io/github/issues/harry0703/MoneyPrinterTurbo.svg?style=for-the-badge" alt="Issues"></a>
|
| 7 |
+
<a href="https://github.com/harry0703/MoneyPrinterTurbo/network/members"><img src="https://img.shields.io/github/forks/harry0703/MoneyPrinterTurbo.svg?style=for-the-badge" alt="Forks"></a>
|
| 8 |
+
<a href="https://github.com/harry0703/MoneyPrinterTurbo/blob/main/LICENSE"><img src="https://img.shields.io/github/license/harry0703/MoneyPrinterTurbo.svg?style=for-the-badge" alt="License"></a>
|
| 9 |
+
</p>
|
| 10 |
+
|
| 11 |
+
<h3>العربية | <a href="README-en.md">English</a> | <a href="README.md">简体中文</a></h3>
|
| 12 |
+
|
| 13 |
+
<div align="center">
|
| 14 |
+
<a href="https://trendshift.io/repositories/8731" target="_blank"><img src="https://trendshift.io/api/badge/repositories/8731" alt="harry0703%2FMoneyPrinterTurbo | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
| 15 |
+
</div>
|
| 16 |
+
|
| 17 |
+
ما عليك سوى تقديم <b>موضوع</b> أو <b>كلمة مفتاحية</b> للفيديو، وسيقوم التطبيق تلقائياً بتوليد نص الفيديو،
|
| 18 |
+
ومواد الفيديو، والترجمة، وموسيقى الخلفية، ثم تركيبها في فيديو قصير عالي الدقة.
|
| 19 |
+
|
| 20 |
+
### واجهة الويب (WebUI)
|
| 21 |
+
|
| 22 |
+

|
| 23 |
+
|
| 24 |
+
### واجهة الـ API
|
| 25 |
+
|
| 26 |
+

|
| 27 |
+
|
| 28 |
+
</div>
|
| 29 |
+
|
| 30 |
+
## المميزات 🎯
|
| 31 |
+
|
| 32 |
+
- [x] بنية **MVC** كاملة، وكود **واضح التنظيم** وسهل الصيانة، يدعم كلاً من `API` و`واجهة الويب`
|
| 33 |
+
- [x] يدعم **توليد نص الفيديو بالذكاء الاصطناعي**، إضافةً إلى **النص المخصّص**
|
| 34 |
+
- [x] يدعم أحجام **فيديو عالي الدقة** متنوعة
|
| 35 |
+
- [x] عمودي 9:16، `1080x1920`
|
| 36 |
+
- [x] أفقي 16:9، `1920x1080`
|
| 37 |
+
- [x] يدعم **توليد الفيديو دفعةً واحدة**، فيمكن إنشاء عدة فيديوهات معاً ثم اختيار الأفضل
|
| 38 |
+
- [x] يدعم ضبط **مدة مقاطع الفيديو**، مما يسهّل التحكم في تكرار تبديل المواد
|
| 39 |
+
- [x] يدعم نص الفيديو بكل من **الصينية** و**الإنجليزية**
|
| 40 |
+
- [x] يدعم **تركيب أصوات متعددة**، مع **معاينة فورية** للنتيجة
|
| 41 |
+
- [x] يدعم **توليد الترجمة**، مع إمكانية ضبط `الخط` و`الموضع` و`اللون` و`الحجم`، كما يدعم `تحديد إطار الترجمة`
|
| 42 |
+
- [x] يدعم **موسيقى الخلفية**، إما عشوائية أو ملفات موسيقى محدّدة، مع إمكانية ضبط `مستوى صوت موسيقى الخلفية`
|
| 43 |
+
- [x] مصادر مواد الفيديو **عالية الدقة** و**خالية من حقوق الملكية**، كما يمكنك استخدام **موادك المحلية** الخاصة
|
| 44 |
+
- [x] يدعم التكامل مع نماذج متعددة مثل **OpenAI** و**Moonshot** و**Azure** و**gpt4free** و**one-api** و**Qwen** و**Google Gemini** و**Ollama** و**DeepSeek** و**MiniMax** و**ERNIE** و**Pollinations** و**ModelScope** وغيرها
|
| 45 |
+
|
| 46 |
+
## عروض فيديو توضيحية 📺
|
| 47 |
+
|
| 48 |
+
### عمودي 9:16
|
| 49 |
+
|
| 50 |
+
<table>
|
| 51 |
+
<thead>
|
| 52 |
+
<tr>
|
| 53 |
+
<th align="center"><g-emoji class="g-emoji" alias="arrow_forward">▶️</g-emoji> كيف تضيف المتعة إلى حياتك </th>
|
| 54 |
+
<th align="center"><g-emoji class="g-emoji" alias="arrow_forward">▶️</g-emoji> ما معنى الحياة</th>
|
| 55 |
+
</tr>
|
| 56 |
+
</thead>
|
| 57 |
+
<tbody>
|
| 58 |
+
<tr>
|
| 59 |
+
<td align="center"><video src="https://github.com/harry0703/MoneyPrinterTurbo/assets/4928832/a84d33d5-27a2-4aba-8fd0-9fb2bd91c6a6"></video></td>
|
| 60 |
+
<td align="center"><video src="https://github.com/harry0703/MoneyPrinterTurbo/assets/4928832/112c9564-d52b-4472-99ad-970b75f66476"></video></td>
|
| 61 |
+
</tr>
|
| 62 |
+
</tbody>
|
| 63 |
+
</table>
|
| 64 |
+
|
| 65 |
+
### أفقي 16:9
|
| 66 |
+
|
| 67 |
+
<table>
|
| 68 |
+
<thead>
|
| 69 |
+
<tr>
|
| 70 |
+
<th align="center"><g-emoji class="g-emoji" alias="arrow_forward">▶️</g-emoji> ما معنى الحياة</th>
|
| 71 |
+
<th align="center"><g-emoji class="g-emoji" alias="arrow_forward">▶️</g-emoji> لماذا تمارس الرياضة</th>
|
| 72 |
+
</tr>
|
| 73 |
+
</thead>
|
| 74 |
+
<tbody>
|
| 75 |
+
<tr>
|
| 76 |
+
<td align="center"><video src="https://github.com/harry0703/MoneyPrinterTurbo/assets/4928832/346ebb15-c55f-47a9-a653-114f08bb8073"></video></td>
|
| 77 |
+
<td align="center"><video src="https://github.com/harry0703/MoneyPrinterTurbo/assets/4928832/271f2fae-8283-44a0-8aa0-0ed8f9a6fa87"></video></td>
|
| 78 |
+
</tr>
|
| 79 |
+
</tbody>
|
| 80 |
+
</table>
|
| 81 |
+
|
| 82 |
+
## متطلبات النظام 📦
|
| 83 |
+
|
| 84 |
+
- المنصّات المُوصى بها: Windows 10+ أو macOS 11+ أو توزيعة Linux رئيسية
|
| 85 |
+
- وجود كرت رسومات (GPU) ليس ضرورياً، لكنه مُستحسَن إن أردت نسخاً صوتياً محلياً أسرع، أو معالجة فيديو أسرع، أو توليداً دفعياً أكثر سلاسة
|
| 86 |
+
|
| 87 |
+
| العنصر | الحد الأدنى | المُوصى به | الأمثل |
|
| 88 |
+
| --- | --- | --- | --- |
|
| 89 |
+
| المعالج (CPU) | 4 أنوية | 6 إلى 8 أنوية | 8+ أنوية |
|
| 90 |
+
| الذاكرة (RAM) | 4 GB | 8 GB | 16+ GB |
|
| 91 |
+
| كرت الرسومات (GPU) | غير مطلوب | 4+ GB VRAM | 8+ GB VRAM |
|
| 92 |
+
|
| 93 |
+
- إذا كنت تعتمد أساساً على نماذج LLM السحابية، وخدمات TTS السحابية، ومصادر المواد عبر الإنترنت، فإن المعالج والذاكرة أهم من كرت الرسومات
|
| 94 |
+
- إذا كنت تستخدم `faster-whisper` أو التوليد الدفعي أو المعالجة المحلية الثقيلة، فسيحسّن كرت الرسومات الإنتاجية بشكل ملحوظ
|
| 95 |
+
|
| 96 |
+
## البدء السريع 🚀
|
| 97 |
+
|
| 98 |
+
### المسارات المُوصى بها
|
| 99 |
+
|
| 100 |
+
- مستخدمو Windows: استخدم الحزمة الجاهزة بنقرة واحدة أولاً للتجربة المحلية الأسرع
|
| 101 |
+
- مستخدمو MacOS / Linux: استخدم `uv sync --frozen` كمسار الإعداد المحلي الأساسي
|
| 102 |
+
- إذا أردت بيئة تشغيل أكثر عزلاً: استخدم النشر عبر Docker
|
| 103 |
+
|
| 104 |
+
### التشغيل في Google Colab
|
| 105 |
+
تريد تجربة MoneyPrinterTurbo دون إعداد بيئة محلية؟ شغّله مباشرةً في Google Colab!
|
| 106 |
+
|
| 107 |
+
[](https://colab.research.google.com/github/harry0703/MoneyPrinterTurbo/blob/main/docs/MoneyPrinterTurbo.ipynb)
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
### Windows
|
| 111 |
+
|
| 112 |
+
الحزمة القابلة للتنزيل ما زالت بناء `v1.2.6` القديم المُجمّع. بعد التنزيل، شغّل `update.bat` أولاً لتحديثه إلى أحدث كود.
|
| 113 |
+
|
| 114 |
+
Google Drive (v1.2.6): https://drive.google.com/file/d/1HsbzfT7XunkrCrHw5ncUjFX8XX4zAuUh/view?usp=sharing
|
| 115 |
+
|
| 116 |
+
بعد التنزيل، يُنصح بالنقر المزدوج على `update.bat` أولاً للتحديث إلى **أحدث كود**، ثم النقر المزدوج على `start.bat` للتشغيل
|
| 117 |
+
|
| 118 |
+
بعد التشغيل، سيُفتح المتصفح تلقائياً (إن فُتح فارغاً، يُنصح باستخدام **Chrome** أو **Edge**)
|
| 119 |
+
|
| 120 |
+
### الأنظمة الأخرى
|
| 121 |
+
|
| 122 |
+
لم تُنشأ حزم تشغيل بنقرة واحدة بعد. راجع قسم **التثبيت والنشر** أدناه. يُنصح باستخدام **docker** للنشر لأنه أكثر سهولة.
|
| 123 |
+
|
| 124 |
+
## التثبيت والنشر 📥
|
| 125 |
+
|
| 126 |
+
### المتطلبات المُسبقة
|
| 127 |
+
|
| 128 |
+
#### ① استنساخ المشروع
|
| 129 |
+
|
| 130 |
+
```shell
|
| 131 |
+
git clone https://github.com/harry0703/MoneyPrinterTurbo.git
|
| 132 |
+
```
|
| 133 |
+
|
| 134 |
+
#### ② تعديل ملف الإعدادات
|
| 135 |
+
|
| 136 |
+
- انسخ ملف `config.example.toml` وأعد تسميته إلى `config.toml`
|
| 137 |
+
- اتبع التعليمات داخل ملف `config.toml` لضبط `pexels_api_keys` و`llm_provider`، وبحسب مزوّد خدمة الـ llm_provider، اضبط مفتاح الـ API المقابل
|
| 138 |
+
|
| 139 |
+
### النشر عبر Docker 🐳
|
| 140 |
+
|
| 141 |
+
#### ① تشغيل حاوية Docker
|
| 142 |
+
|
| 143 |
+
إذا لم تكن قد ثبّت Docker، فثبّته أولاً https://www.docker.com/products/docker-desktop/
|
| 144 |
+
إذا كنت تستخدم نظام Windows، فراجع وثائق Microsoft:
|
| 145 |
+
|
| 146 |
+
1. https://learn.microsoft.com/en-us/windows/wsl/install
|
| 147 |
+
2. https://learn.microsoft.com/en-us/windows/wsl/tutorials/wsl-containers
|
| 148 |
+
|
| 149 |
+
```shell
|
| 150 |
+
cd MoneyPrinterTurbo
|
| 151 |
+
docker-compose up
|
| 152 |
+
```
|
| 153 |
+
|
| 154 |
+
> ملاحظة: أحدث إصدار من docker يثبّت docker compose تلقائياً على هيئة إضافة (plug-in)، ويتغيّر أمر التشغيل إلى `docker compose up`
|
| 155 |
+
|
| 156 |
+
#### ② الوصول إلى واجهة الويب
|
| 157 |
+
|
| 158 |
+
افتح متصفحك وزر http://127.0.0.1:8501
|
| 159 |
+
|
| 160 |
+
#### ③ الوصول إلى واجهة الـ API
|
| 161 |
+
|
| 162 |
+
افتح متصفحك وزر http://0.0.0.0:8080/docs أو http://0.0.0.0:8080/redoc
|
| 163 |
+
|
| 164 |
+
### النشر اليدوي 📦
|
| 165 |
+
|
| 166 |
+
#### ① إنشاء بيئة Python افتراضية
|
| 167 |
+
|
| 168 |
+
يُنصح باستخدام [uv](https://docs.astral.sh/uv/) لإدارة بيئة Python والاعتماديات، مع Python `3.11` كبيئة تشغيل افتراضية.
|
| 169 |
+
|
| 170 |
+
```shell
|
| 171 |
+
git clone https://github.com/harry0703/MoneyPrinterTurbo.git
|
| 172 |
+
cd MoneyPrinterTurbo
|
| 173 |
+
uv python install 3.11
|
| 174 |
+
uv sync --frozen
|
| 175 |
+
```
|
| 176 |
+
|
| 177 |
+
إذا كنت لا تستخدم `uv` بعد، فما زال بإمكانك استخدام `venv + pip`.
|
| 178 |
+
|
| 179 |
+
```shell
|
| 180 |
+
python3.11 -m venv .venv
|
| 181 |
+
source .venv/bin/activate
|
| 182 |
+
pip install -r requirements.txt
|
| 183 |
+
```
|
| 184 |
+
|
| 185 |
+
ملاحظات:
|
| 186 |
+
- أصبح `pyproject.toml` هو ملف الاعتماديات الأساسي.
|
| 187 |
+
- يُثبّت `uv.lock` البيئة المُحدّدة، لذا يُنصح بـ `uv sync --frozen` افتراضياً.
|
| 188 |
+
- يُحتفظ بـ `requirements.txt` فقط للتثبيت القديم المعتمد على `pip`.
|
| 189 |
+
|
| 190 |
+
#### ② تثبيت ImageMagick
|
| 191 |
+
|
| 192 |
+
###### Windows:
|
| 193 |
+
|
| 194 |
+
- نزّل من https://imagemagick.org/script/download.php واختر نسخة Windows، وتأكد من اختيار نسخة **المكتبة الساكنة (static library)**، مثل ImageMagick-7.1.1-32-Q16-x64-**static**.exe
|
| 195 |
+
- ثبّت ImageMagick الذي نزّلته، **ولا تغيّر مسار التثبيت**
|
| 196 |
+
- عدّل ملف الإعدادات `config.toml`، واضبط `imagemagick_path` على مسار التثبيت الفعلي لديك
|
| 197 |
+
|
| 198 |
+
###### MacOS:
|
| 199 |
+
|
| 200 |
+
```shell
|
| 201 |
+
brew install imagemagick
|
| 202 |
+
```
|
| 203 |
+
|
| 204 |
+
###### Ubuntu
|
| 205 |
+
|
| 206 |
+
```shell
|
| 207 |
+
sudo apt-get install imagemagick
|
| 208 |
+
```
|
| 209 |
+
|
| 210 |
+
###### CentOS
|
| 211 |
+
|
| 212 |
+
```shell
|
| 213 |
+
sudo yum install ImageMagick
|
| 214 |
+
```
|
| 215 |
+
|
| 216 |
+
#### ③ تشغيل واجهة الويب 🌐
|
| 217 |
+
|
| 218 |
+
لاحظ أنك بحاجة لتنفيذ الأوامر التالية في `المجلد الجذر` لمشروع MoneyPrinterTurbo
|
| 219 |
+
|
| 220 |
+
###### Windows
|
| 221 |
+
|
| 222 |
+
```shell
|
| 223 |
+
uv run streamlit run ./webui/Main.py --browser.gatherUsageStats=False
|
| 224 |
+
```
|
| 225 |
+
|
| 226 |
+
إذا كنت قد فعّلت البيئة الافتراضية يدوياً، فما زال بإمكانك تشغيل:
|
| 227 |
+
|
| 228 |
+
```bat
|
| 229 |
+
webui.bat
|
| 230 |
+
```
|
| 231 |
+
|
| 232 |
+
###### MacOS أو Linux
|
| 233 |
+
|
| 234 |
+
```shell
|
| 235 |
+
uv run streamlit run ./webui/Main.py --browser.gatherUsageStats=False
|
| 236 |
+
```
|
| 237 |
+
|
| 238 |
+
إذا كنت قد فعّلت البيئة الافتراضية يدوياً، فما زال بإمكانك تشغيل:
|
| 239 |
+
|
| 240 |
+
```shell
|
| 241 |
+
sh webui.sh
|
| 242 |
+
```
|
| 243 |
+
|
| 244 |
+
بعد التشغيل، سيُفتح المتصفح تلقائياً
|
| 245 |
+
|
| 246 |
+
#### ④ تشغيل خدمة الـ API 🚀
|
| 247 |
+
|
| 248 |
+
```shell
|
| 249 |
+
uv run python main.py
|
| 250 |
+
```
|
| 251 |
+
|
| 252 |
+
إذا كنت قد فعّلت البيئة الافتراضية يدوياً، فما زال بإمكانك تشغيل:
|
| 253 |
+
|
| 254 |
+
```shell
|
| 255 |
+
python main.py
|
| 256 |
+
```
|
| 257 |
+
|
| 258 |
+
## شكر خاص 🙏
|
| 259 |
+
|
| 260 |
+
نظراً لأن **نشر** و**استخدام** هذا المشروع يمثّل عتبةً معينة لبعض المستخدمين المبتدئين، نودّ أن نتقدّم بشكر خاص إلى
|
| 261 |
+
|
| 262 |
+
**RecCloud (منصة خدمات وسائط متعددة مدعومة بالذكاء الاصطناعي)** لتقديمها خدمة `AI Video Generator` مجانية مبنية على هذا
|
| 263 |
+
المشروع. فهي تتيح الاستخدام عبر الإنترنت دون نشر، وهو أمر مريح للغاية.
|
| 264 |
+
|
| 265 |
+
- النسخة الصينية: https://reccloud.cn
|
| 266 |
+
- النسخة الإنجليزية: https://reccloud.com
|
| 267 |
+
|
| 268 |
+

|
| 269 |
+
|
| 270 |
+
## شكراً للرعاية 🙏
|
| 271 |
+
|
| 272 |
+
شكراً لـ Picwish https://picwish.com على دعمها ورعايتها لهذا المشروع، مما يتيح التحديث والصيانة المستمرّين.
|
| 273 |
+
|
| 274 |
+
تركّز Picwish على **مجال معالجة الصور**، وتوفّر مجموعة غنية من **أدوات معالجة الصور** التي تبسّط العمليات المعقّدة إلى حدٍّ بعيد، فتجعل معالجة الصور أسهل حقاً.
|
| 275 |
+
|
| 276 |
+

|
| 277 |
+
|
| 278 |
+
بعد التشغيل، يمكنك عرض `وثائق الـ API` على http://127.0.0.1:8080/docs واختبار الواجهة مباشرةً عبر الإنترنت
|
| 279 |
+
لتجربة سريعة.
|
| 280 |
+
|
| 281 |
+
## تركيب الصوت 🗣
|
| 282 |
+
|
| 283 |
+
يمكن عرض قائمة بجميع الأصوات المدعومة هنا: [قائمة الأصوات](./docs/voice-list.txt)
|
| 284 |
+
|
| 285 |
+
2024-04-16 v1.1.2 أُضيفت 9 أصوات تركيب صوتي جديدة من Azure تتطلب ضبط مفتاح API. هذه الأصوات تبدو أكثر واقعية.
|
| 286 |
+
|
| 287 |
+
## توليد الترجمة 📜
|
| 288 |
+
|
| 289 |
+
حالياً، هناك طريقتان لتوليد الترجمة:
|
| 290 |
+
|
| 291 |
+
- **edge**: سرعة توليد أعلى، وأداء أفضل، ولا متطلبات خاصة لمواصفات الحاسوب، لكن الجودة قد تكون غير مستقرة
|
| 292 |
+
- **whisper**: سرعة توليد أبطأ، وأداء أضعف، ومتطلبات خاصة لمواصفات الحاسوب، لكن الجودة أكثر موثوقية
|
| 293 |
+
|
| 294 |
+
يمكنك التبديل بينهما بتعديل `subtitle_provider` في ملف الإعدادات `config.toml`
|
| 295 |
+
|
| 296 |
+
يُنصح باستخدام وضع `edge`، والتبديل إلى وضع `whisper` إذا كانت جودة الترجمة المُولّدة غير مُرضية.
|
| 297 |
+
|
| 298 |
+
> ملاحظة:
|
| 299 |
+
>
|
| 300 |
+
> 1. في وضع whisper، تحتاج إلى تنزيل ملف نموذج من HuggingFace بحجم نحو 3GB، فتأكد من اتصال إنترنت جيد
|
| 301 |
+
> 2. إذا تُرك فارغاً، فهذا يعني أنه لن تُولَّد أي ترجمة.
|
| 302 |
+
|
| 303 |
+
> بما أن HuggingFace غير متاح في الصين، يمكنك استخدام الطرق التالية لتنزيل ملف نموذج `whisper-large-v3`
|
| 304 |
+
|
| 305 |
+
روابط التنزيل:
|
| 306 |
+
|
| 307 |
+
- Baidu Netdisk: https://pan.baidu.com/s/11h3Q6tsDtjQKTjUu3sc5cA?pwd=xjs9
|
| 308 |
+
- Quark Netdisk: https://pan.quark.cn/s/3ee3d991d64b
|
| 309 |
+
|
| 310 |
+
بعد تنزيل النموذج، فُكّ ضغطه وضع المجلد بالكامل في `.\MoneyPrinterTurbo\models`،
|
| 311 |
+
وينبغي أن يبدو مسار الملف النهائي هكذا: `.\MoneyPrinterTurbo\models\whisper-large-v3`
|
| 312 |
+
|
| 313 |
+
```
|
| 314 |
+
MoneyPrinterTurbo
|
| 315 |
+
├─models
|
| 316 |
+
│ └─whisper-large-v3
|
| 317 |
+
│ config.json
|
| 318 |
+
│ model.bin
|
| 319 |
+
│ preprocessor_config.json
|
| 320 |
+
│ tokenizer.json
|
| 321 |
+
│ vocabulary.json
|
| 322 |
+
```
|
| 323 |
+
|
| 324 |
+
## موسيقى الخلفية 🎵
|
| 325 |
+
|
| 326 |
+
تقع موسيقى خلفية الفيديوهات في مجلد المشروع `resource/songs`.
|
| 327 |
+
> يتضمّن المشروع الحالي بعض الموسيقى الافتراضية من فيديوهات YouTube. إن وُجدت مشكلات حقوق نشر، فالرجاء حذفها.
|
| 328 |
+
|
| 329 |
+
## خطوط الترجمة 🅰
|
| 330 |
+
|
| 331 |
+
تقع خطوط عرض ترجمة الفيديو في مجلد المشروع `resource/fonts`، ويمكنك أيضاً إضافة خطوطك الخاصة.
|
| 332 |
+
|
| 333 |
+
## الأسئلة الشائعة 🤔
|
| 334 |
+
|
| 335 |
+
### ❓RuntimeError: No ffmpeg exe could be found
|
| 336 |
+
|
| 337 |
+
في الوضع الطبيعي، يُنزَّل ffmpeg ويُكتشَف تلقائياً.
|
| 338 |
+
لكن إذا كانت بيئتك تعاني مشكلات تمنع التنزيل التلقائي، فقد تواجه الخطأ التالي:
|
| 339 |
+
|
| 340 |
+
```
|
| 341 |
+
RuntimeError: No ffmpeg exe could be found.
|
| 342 |
+
Install ffmpeg on your system, or set the IMAGEIO_FFMPEG_EXE environment variable.
|
| 343 |
+
```
|
| 344 |
+
|
| 345 |
+
في هذه الحالة، يمكنك تنزيل ffmpeg من https://www.gyan.dev/ffmpeg/builds/ ثم فك ضغطه وضبط `ffmpeg_path` على مسار
|
| 346 |
+
التثبيت الفعلي لديك.
|
| 347 |
+
|
| 348 |
+
```toml
|
| 349 |
+
[app]
|
| 350 |
+
# الرجاء الضبط بحسب مسارك الفعلي، ولاحظ أن فاصل المسارات في Windows هو \\
|
| 351 |
+
ffmpeg_path = "C:\\Users\\harry\\Downloads\\ffmpeg.exe"
|
| 352 |
+
```
|
| 353 |
+
|
| 354 |
+
### ❓ImageMagick is not installed on your computer
|
| 355 |
+
|
| 356 |
+
[issue 33](https://github.com/harry0703/MoneyPrinterTurbo/issues/33)
|
| 357 |
+
|
| 358 |
+
1. اتبع `عنوان التنزيل` الموجود في `الإعداد النموذجي`
|
| 359 |
+
لتثبيت https://imagemagick.org/archive/binaries/ImageMagick-7.1.1-30-Q16-x64-static.exe (باستخدام المكتبة الساكنة)
|
| 360 |
+
2. لا تثبّت في مسار يحتوي على أحرف صينية لتجنّب مشكلات غير متوقّعة
|
| 361 |
+
|
| 362 |
+
[issue 54](https://github.com/harry0703/MoneyPrinterTurbo/issues/54#issuecomment-2017842022)
|
| 363 |
+
|
| 364 |
+
لأنظمة Linux، يمكنك تثبيته يدوياً، راجع https://cn.linux-console.net/?p=16978
|
| 365 |
+
|
| 366 |
+
شكراً لـ [@wangwenqiao666](https://github.com/wangwenqiao666) على بحثه واستكشافه
|
| 367 |
+
|
| 368 |
+
### ❓ImageMagick's security policy prevents operations related to temporary file @/tmp/tmpur5hyyto.txt
|
| 369 |
+
|
| 370 |
+
يمكنك إيجاد هذه السياسات في ملف إعدادات ImageMagick policy.xml.
|
| 371 |
+
يقع هذا الملف عادةً في /etc/ImageMagick-`X`/ أو موقع مشابه في مجلد تثبيت ImageMagick.
|
| 372 |
+
عدّل المُدخل الذي يحتوي على `pattern="@"`، وغيّر `rights="none"` إلى `rights="read|write"` للسماح بعمليات القراءة والكتابة على الملفات.
|
| 373 |
+
|
| 374 |
+
### ❓OSError: [Errno 24] Too many open files
|
| 375 |
+
|
| 376 |
+
تنتج هذه المشكلة عن حدّ النظام لعدد الملفات المفتوحة. يمكنك حلّها بتعديل حدّ فتح الملفات في النظام.
|
| 377 |
+
|
| 378 |
+
تحقّق من الحدّ الحالي:
|
| 379 |
+
|
| 380 |
+
```shell
|
| 381 |
+
ulimit -n
|
| 382 |
+
```
|
| 383 |
+
|
| 384 |
+
إن كان منخفضاً جداً، يمكنك زيادته، مثلاً:
|
| 385 |
+
|
| 386 |
+
```shell
|
| 387 |
+
ulimit -n 10240
|
| 388 |
+
```
|
| 389 |
+
|
| 390 |
+
### ❓Whisper model download failed, with the following error
|
| 391 |
+
|
| 392 |
+
LocalEntryNotfoundEror: Cannot find an appropriate cached snapshotfolderfor the specified revision on the local disk and
|
| 393 |
+
outgoing trafic has been disabled.
|
| 394 |
+
To enablerepo look-ups and downloads online, pass 'local files only=False' as input.
|
| 395 |
+
|
| 396 |
+
أو
|
| 397 |
+
|
| 398 |
+
An error occurred while synchronizing the model Systran/faster-whisper-large-v3 from the Hugging Face Hub:
|
| 399 |
+
An error happened while trying to locate the files on the Hub and we cannot find the appropriate snapshot folder for the
|
| 400 |
+
specified revision on the local disk. Please check your internet connection and try again.
|
| 401 |
+
Trying to load the model directly from the local cache, if it exists.
|
| 402 |
+
|
| 403 |
+
الحل: [اضغط لمعرفة كيفية تنزيل النموذج يدوياً من قرص الشبكة](#توليد-الترجمة-)
|
| 404 |
+
|
| 405 |
+
## الملاحظات والاقتراحات 📢
|
| 406 |
+
|
| 407 |
+
- يمكنك إرسال [issue](https://github.com/harry0703/MoneyPrinterTurbo/issues) أو
|
| 408 |
+
[pull request](https://github.com/harry0703/MoneyPrinterTurbo/pulls).
|
| 409 |
+
|
| 410 |
+
## الرخصة 📝
|
| 411 |
+
|
| 412 |
+
اضغط لعرض ملف [`LICENSE`](LICENSE)
|
| 413 |
+
|
| 414 |
+
## تاريخ النجوم (Star History)
|
| 415 |
+
|
| 416 |
+
[](https://star-history.com/#harry0703/MoneyPrinterTurbo&Date)
|
README-en.md
ADDED
|
@@ -0,0 +1,433 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<div align="center">
|
| 2 |
+
<h1 align="center">MoneyPrinterTurbo 💸</h1>
|
| 3 |
+
|
| 4 |
+
<p align="center">
|
| 5 |
+
<a href="https://github.com/harry0703/MoneyPrinterTurbo/stargazers"><img src="https://img.shields.io/github/stars/harry0703/MoneyPrinterTurbo.svg?style=for-the-badge" alt="Stargazers"></a>
|
| 6 |
+
<a href="https://github.com/harry0703/MoneyPrinterTurbo/issues"><img src="https://img.shields.io/github/issues/harry0703/MoneyPrinterTurbo.svg?style=for-the-badge" alt="Issues"></a>
|
| 7 |
+
<a href="https://github.com/harry0703/MoneyPrinterTurbo/network/members"><img src="https://img.shields.io/github/forks/harry0703/MoneyPrinterTurbo.svg?style=for-the-badge" alt="Forks"></a>
|
| 8 |
+
<a href="https://github.com/harry0703/MoneyPrinterTurbo/blob/main/LICENSE"><img src="https://img.shields.io/github/license/harry0703/MoneyPrinterTurbo.svg?style=for-the-badge" alt="License"></a>
|
| 9 |
+
</p>
|
| 10 |
+
|
| 11 |
+
<h3>English | <a href="README.md">简体中文</a> | <a href="README-ar.md">العربية</a></h3>
|
| 12 |
+
|
| 13 |
+
<div align="center">
|
| 14 |
+
<a href="https://trendshift.io/repositories/8731" target="_blank"><img src="https://trendshift.io/api/badge/repositories/8731" alt="harry0703%2FMoneyPrinterTurbo | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
| 15 |
+
</div>
|
| 16 |
+
|
| 17 |
+
Simply provide a <b>topic</b> or <b>keyword</b> for a video, and it will automatically generate the video copy, video
|
| 18 |
+
materials, video subtitles, and video background music before synthesizing a high-definition short video.
|
| 19 |
+
|
| 20 |
+
### WebUI
|
| 21 |
+
|
| 22 |
+

|
| 23 |
+
|
| 24 |
+
### API Interface
|
| 25 |
+
|
| 26 |
+

|
| 27 |
+
|
| 28 |
+
</div>
|
| 29 |
+
|
| 30 |
+
<p align="center">
|
| 31 |
+
<sub>
|
| 32 |
+
Thanks to <a href="https://aihubmix.com/?aff=CEve">AIHubMix</a> for sponsoring this project. AIHubMix deeply adapts to OpenAI, Claude, Gemini, DeepSeek, Zhipu, Qwen, and other leading models, providing one-stop access to GPT-5.5, deepseek-v4-flash, and 700+ models including free options with production-grade stability.
|
| 33 |
+
</sub>
|
| 34 |
+
</p>
|
| 35 |
+
|
| 36 |
+
## Features 🎯
|
| 37 |
+
|
| 38 |
+
- [x] Complete **MVC architecture**, **clearly structured** code, easy to maintain, supports both `API`
|
| 39 |
+
and `Web interface`
|
| 40 |
+
- [x] Supports **AI-generated** video copy, as well as **customized copy**
|
| 41 |
+
- [x] Supports various **high-definition video** sizes
|
| 42 |
+
- [x] Portrait 9:16, `1080x1920`
|
| 43 |
+
- [x] Landscape 16:9, `1920x1080`
|
| 44 |
+
- [x] Supports **batch video generation**, allowing the creation of multiple videos at once, then selecting the most
|
| 45 |
+
satisfactory one
|
| 46 |
+
- [x] Supports setting the **duration of video clips**, facilitating adjustments to material switching frequency
|
| 47 |
+
- [x] Supports video copy in both **Chinese** and **English**
|
| 48 |
+
- [x] Supports **multiple voice** synthesis, with **real-time preview** of effects
|
| 49 |
+
- [x] Supports **subtitle generation**, with adjustable `font`, `position`, `color`, `size`, and also
|
| 50 |
+
supports `subtitle outlining`
|
| 51 |
+
- [x] Supports **background music**, either random or specified music files, with adjustable `background music volume`
|
| 52 |
+
- [x] Video material sources are **high-definition** and **royalty-free**, and you can also use your own **local materials**
|
| 53 |
+
- [x] Supports integration with various models such as **OpenAI**, **AIHubMix**, **Moonshot**, **Azure**, **gpt4free**, **one-api**, **Qwen**, **Google Gemini**, **Ollama**, **DeepSeek**, **MiniMax**, **ERNIE**, **Pollinations**, **ModelScope** and more
|
| 54 |
+
|
| 55 |
+
## Video Demos 📺
|
| 56 |
+
|
| 57 |
+
### Portrait 9:16
|
| 58 |
+
|
| 59 |
+
<table>
|
| 60 |
+
<thead>
|
| 61 |
+
<tr>
|
| 62 |
+
<th align="center"><g-emoji class="g-emoji" alias="arrow_forward">▶️</g-emoji> How to Add Fun to Your Life </th>
|
| 63 |
+
<th align="center"><g-emoji class="g-emoji" alias="arrow_forward">▶️</g-emoji> What is the Meaning of Life</th>
|
| 64 |
+
</tr>
|
| 65 |
+
</thead>
|
| 66 |
+
<tbody>
|
| 67 |
+
<tr>
|
| 68 |
+
<td align="center"><video src="https://github.com/harry0703/MoneyPrinterTurbo/assets/4928832/a84d33d5-27a2-4aba-8fd0-9fb2bd91c6a6"></video></td>
|
| 69 |
+
<td align="center"><video src="https://github.com/harry0703/MoneyPrinterTurbo/assets/4928832/112c9564-d52b-4472-99ad-970b75f66476"></video></td>
|
| 70 |
+
</tr>
|
| 71 |
+
</tbody>
|
| 72 |
+
</table>
|
| 73 |
+
|
| 74 |
+
### Landscape 16:9
|
| 75 |
+
|
| 76 |
+
<table>
|
| 77 |
+
<thead>
|
| 78 |
+
<tr>
|
| 79 |
+
<th align="center"><g-emoji class="g-emoji" alias="arrow_forward">▶️</g-emoji> What is the Meaning of Life</th>
|
| 80 |
+
<th align="center"><g-emoji class="g-emoji" alias="arrow_forward">▶️</g-emoji> Why Exercise</th>
|
| 81 |
+
</tr>
|
| 82 |
+
</thead>
|
| 83 |
+
<tbody>
|
| 84 |
+
<tr>
|
| 85 |
+
<td align="center"><video src="https://github.com/harry0703/MoneyPrinterTurbo/assets/4928832/346ebb15-c55f-47a9-a653-114f08bb8073"></video></td>
|
| 86 |
+
<td align="center"><video src="https://github.com/harry0703/MoneyPrinterTurbo/assets/4928832/271f2fae-8283-44a0-8aa0-0ed8f9a6fa87"></video></td>
|
| 87 |
+
</tr>
|
| 88 |
+
</tbody>
|
| 89 |
+
</table>
|
| 90 |
+
|
| 91 |
+
## System Requirements 📦
|
| 92 |
+
|
| 93 |
+
- Recommended platforms: Windows 10+, macOS 11+, or a mainstream Linux distribution
|
| 94 |
+
- A GPU is not required, but it is recommended if you want faster local transcription, faster video processing, or smoother batch generation
|
| 95 |
+
|
| 96 |
+
| Item | Minimum | Recommended | Optimal |
|
| 97 |
+
| ---- | ------------ | ------------ | ---------- |
|
| 98 |
+
| CPU | 4 cores | 6 to 8 cores | 8+ cores |
|
| 99 |
+
| RAM | 4 GB | 8 GB | 16+ GB |
|
| 100 |
+
| GPU | Not required | 4+ GB VRAM | 8+ GB VRAM |
|
| 101 |
+
|
| 102 |
+
- If you mainly rely on cloud LLMs, cloud TTS, and online material sources, CPU and RAM matter more than GPU
|
| 103 |
+
- If you use `faster-whisper`, batch generation, or heavier local processing, a GPU will improve throughput noticeably
|
| 104 |
+
|
| 105 |
+
## Quick Start 🚀
|
| 106 |
+
|
| 107 |
+
### Recommended Paths
|
| 108 |
+
|
| 109 |
+
- Windows users: use the one-click package first for the fastest local trial
|
| 110 |
+
- MacOS / Linux users: use `uv sync --frozen` for the primary local setup path
|
| 111 |
+
- If you want a more isolated runtime: use Docker deployment
|
| 112 |
+
|
| 113 |
+
### Run in Google Colab
|
| 114 |
+
|
| 115 |
+
Want to try MoneyPrinterTurbo without setting up a local environment? Run it directly in Google Colab!
|
| 116 |
+
|
| 117 |
+
[](https://colab.research.google.com/github/harry0703/MoneyPrinterTurbo/blob/main/docs/MoneyPrinterTurbo.ipynb)
|
| 118 |
+
|
| 119 |
+
### Windows
|
| 120 |
+
|
| 121 |
+
The downloadable package is still the older `v1.2.6` bundled build. After downloading, run `update.bat` first to bring it up to the latest code.
|
| 122 |
+
|
| 123 |
+
Google Drive (v1.2.6): https://drive.google.com/file/d/1HsbzfT7XunkrCrHw5ncUjFX8XX4zAuUh/view?usp=sharing
|
| 124 |
+
|
| 125 |
+
After downloading, it is recommended to **double-click** `update.bat` first to update to the **latest code**, then double-click `start.bat` to launch
|
| 126 |
+
|
| 127 |
+
After launching, the browser will open automatically (if it opens blank, it is recommended to use **Chrome** or **Edge**)
|
| 128 |
+
|
| 129 |
+
### Other Systems
|
| 130 |
+
|
| 131 |
+
One-click startup packages have not been created yet. See the **Installation & Deployment** section below. It is recommended to use **docker** for deployment, which is more convenient.
|
| 132 |
+
|
| 133 |
+
## Installation & Deployment 📥
|
| 134 |
+
|
| 135 |
+
### Prerequisites
|
| 136 |
+
|
| 137 |
+
#### ① Clone the Project
|
| 138 |
+
|
| 139 |
+
```shell
|
| 140 |
+
git clone https://github.com/harry0703/MoneyPrinterTurbo.git
|
| 141 |
+
```
|
| 142 |
+
|
| 143 |
+
#### ② Modify the Configuration File
|
| 144 |
+
|
| 145 |
+
- Copy the `config.example.toml` file and rename it to `config.toml`
|
| 146 |
+
- Follow the instructions in the `config.toml` file to configure `pexels_api_keys` and `llm_provider`, and according to
|
| 147 |
+
the llm_provider's service provider, set up the corresponding API Key
|
| 148 |
+
- To use the recommended multi-model provider, you can set `llm_provider` to `aihubmix` and enter the corresponding API key.
|
| 149 |
+
|
| 150 |
+
### Docker Deployment 🐳
|
| 151 |
+
|
| 152 |
+
#### ① Launch the Docker Container
|
| 153 |
+
|
| 154 |
+
If you haven't installed Docker, please install it first https://www.docker.com/products/docker-desktop/
|
| 155 |
+
If you are using a Windows system, please refer to Microsoft's documentation:
|
| 156 |
+
|
| 157 |
+
1. https://learn.microsoft.com/en-us/windows/wsl/install
|
| 158 |
+
2. https://learn.microsoft.com/en-us/windows/wsl/tutorials/wsl-containers
|
| 159 |
+
|
| 160 |
+
```shell
|
| 161 |
+
cd MoneyPrinterTurbo
|
| 162 |
+
docker-compose up
|
| 163 |
+
```
|
| 164 |
+
|
| 165 |
+
> Note:The latest version of docker will automatically install docker compose in the form of a plug-in, and the start command is adjusted to `docker compose up `
|
| 166 |
+
|
| 167 |
+
#### ② Access the Web Interface
|
| 168 |
+
|
| 169 |
+
Open your browser and visit http://127.0.0.1:8501
|
| 170 |
+
|
| 171 |
+
#### ③ Access the API Interface
|
| 172 |
+
|
| 173 |
+
Open your browser and visit http://0.0.0.0:8080/docs Or http://0.0.0.0:8080/redoc
|
| 174 |
+
|
| 175 |
+
### Manual Deployment 📦
|
| 176 |
+
|
| 177 |
+
#### ① Create a Python Virtual Environment
|
| 178 |
+
|
| 179 |
+
It is recommended to use [uv](https://docs.astral.sh/uv/) to manage the Python environment and dependencies, with Python `3.11` as the default runtime.
|
| 180 |
+
|
| 181 |
+
```shell
|
| 182 |
+
git clone https://github.com/harry0703/MoneyPrinterTurbo.git
|
| 183 |
+
cd MoneyPrinterTurbo
|
| 184 |
+
uv python install 3.11
|
| 185 |
+
uv sync --frozen
|
| 186 |
+
```
|
| 187 |
+
|
| 188 |
+
If you are not using `uv` yet, you can still use `venv + pip`.
|
| 189 |
+
|
| 190 |
+
```shell
|
| 191 |
+
python3.11 -m venv .venv
|
| 192 |
+
source .venv/bin/activate
|
| 193 |
+
pip install -r requirements.txt
|
| 194 |
+
```
|
| 195 |
+
|
| 196 |
+
Notes:
|
| 197 |
+
|
| 198 |
+
- `pyproject.toml` is now the primary dependency manifest.
|
| 199 |
+
- `uv.lock` pins the resolved environment, so `uv sync --frozen` is recommended by default.
|
| 200 |
+
- `requirements.txt` is kept only for legacy `pip`-based installation.
|
| 201 |
+
|
| 202 |
+
#### ② Install ImageMagick
|
| 203 |
+
|
| 204 |
+
###### Windows:
|
| 205 |
+
|
| 206 |
+
- Download https://imagemagick.org/script/download.php Choose the Windows version, make sure to select the **static library** version, such as ImageMagick-7.1.1-32-Q16-x64-**static**.exe
|
| 207 |
+
- Install the downloaded ImageMagick, **do not change the installation path**
|
| 208 |
+
- Modify the `config.toml` configuration file, set `imagemagick_path` to your actual installation path
|
| 209 |
+
|
| 210 |
+
###### MacOS:
|
| 211 |
+
|
| 212 |
+
```shell
|
| 213 |
+
brew install imagemagick
|
| 214 |
+
```
|
| 215 |
+
|
| 216 |
+
###### Ubuntu
|
| 217 |
+
|
| 218 |
+
```shell
|
| 219 |
+
sudo apt-get install imagemagick
|
| 220 |
+
```
|
| 221 |
+
|
| 222 |
+
###### CentOS
|
| 223 |
+
|
| 224 |
+
```shell
|
| 225 |
+
sudo yum install ImageMagick
|
| 226 |
+
```
|
| 227 |
+
|
| 228 |
+
#### ③ Launch the Web Interface 🌐
|
| 229 |
+
|
| 230 |
+
Note that you need to execute the following commands in the `root directory` of the MoneyPrinterTurbo project
|
| 231 |
+
|
| 232 |
+
###### Windows
|
| 233 |
+
|
| 234 |
+
```powershell
|
| 235 |
+
.\webui.bat
|
| 236 |
+
```
|
| 237 |
+
|
| 238 |
+
You can also run `webui.bat` in CMD.
|
| 239 |
+
`webui.bat` prefers the project `.venv` or bundled Python from the portable package. If no project Python is found but `uv` is installed, it automatically falls back to `uv run streamlit`.
|
| 240 |
+
To allow other devices on your LAN to access the WebUI, run `set MPT_WEBUI_HOST=0.0.0.0` before running `webui.bat`.
|
| 241 |
+
|
| 242 |
+
###### MacOS or Linux
|
| 243 |
+
|
| 244 |
+
```shell
|
| 245 |
+
uv run streamlit run ./webui/Main.py --browser.gatherUsageStats=False
|
| 246 |
+
```
|
| 247 |
+
|
| 248 |
+
If you have already activated the virtual environment manually, you can still run:
|
| 249 |
+
|
| 250 |
+
```shell
|
| 251 |
+
sh webui.sh
|
| 252 |
+
```
|
| 253 |
+
|
| 254 |
+
After launching, the browser will open automatically
|
| 255 |
+
|
| 256 |
+
#### ④ Launch the API Service 🚀
|
| 257 |
+
|
| 258 |
+
```shell
|
| 259 |
+
uv run python main.py
|
| 260 |
+
```
|
| 261 |
+
|
| 262 |
+
If you have already activated the virtual environment manually, you can still run:
|
| 263 |
+
|
| 264 |
+
```shell
|
| 265 |
+
python main.py
|
| 266 |
+
```
|
| 267 |
+
|
| 268 |
+
## Special Thanks 🙏
|
| 269 |
+
|
| 270 |
+
Due to the **deployment** and **usage** of this project, there is a certain threshold for some beginner users. We would
|
| 271 |
+
like to express our special thanks to
|
| 272 |
+
|
| 273 |
+
**RecCloud (AI-Powered Multimedia Service Platform)** for providing a free `AI Video Generator` service based on this
|
| 274 |
+
project. It allows for online use without deployment, which is very convenient.
|
| 275 |
+
|
| 276 |
+
- Chinese version: https://reccloud.cn
|
| 277 |
+
- English version: https://reccloud.com
|
| 278 |
+
|
| 279 |
+

|
| 280 |
+
|
| 281 |
+
## Thanks for Sponsorship 🙏
|
| 282 |
+
|
| 283 |
+
Thanks to Picwish https://picwish.com for supporting and sponsoring this project, enabling continuous updates and maintenance.
|
| 284 |
+
|
| 285 |
+
Picwish focuses on the **image processing field**, providing a rich set of **image processing tools** that extremely simplify complex operations, truly making image processing easier.
|
| 286 |
+
|
| 287 |
+

|
| 288 |
+
|
| 289 |
+
After launching, you can view the `API documentation` at http://127.0.0.1:8080/docs and directly test the interface
|
| 290 |
+
online for a quick experience.
|
| 291 |
+
|
| 292 |
+
## Voice Synthesis 🗣
|
| 293 |
+
|
| 294 |
+
A list of all supported voices can be viewed here: [Voice List](./docs/voice-list.txt)
|
| 295 |
+
|
| 296 |
+
2024-04-16 v1.1.2 Added 9 new Azure voice synthesis voices that require API KEY configuration. These voices sound more realistic.
|
| 297 |
+
|
| 298 |
+
## Subtitle Generation 📜
|
| 299 |
+
|
| 300 |
+
Currently, there are 2 ways to generate subtitles:
|
| 301 |
+
|
| 302 |
+
- **edge**: Faster generation speed, better performance, no specific requirements for computer configuration, but the
|
| 303 |
+
quality may be unstable
|
| 304 |
+
- **whisper**: Slower generation speed, poorer performance, specific requirements for computer configuration, but more
|
| 305 |
+
reliable quality
|
| 306 |
+
|
| 307 |
+
You can switch between them by modifying the `subtitle_provider` in the `config.toml` configuration file
|
| 308 |
+
|
| 309 |
+
It is recommended to use `edge` mode, and switch to `whisper` mode if the quality of the subtitles generated is not
|
| 310 |
+
satisfactory.
|
| 311 |
+
|
| 312 |
+
> Note:
|
| 313 |
+
>
|
| 314 |
+
> 1. In whisper mode, you need to download a model file from HuggingFace, about 3GB in size, please ensure good internet connectivity
|
| 315 |
+
> 2. If left blank, it means no subtitles will be generated.
|
| 316 |
+
|
| 317 |
+
> Since HuggingFace is not accessible in China, you can use the following methods to download the `whisper-large-v3` model file
|
| 318 |
+
|
| 319 |
+
Download links:
|
| 320 |
+
|
| 321 |
+
- Baidu Netdisk: https://pan.baidu.com/s/11h3Q6tsDtjQKTjUu3sc5cA?pwd=xjs9
|
| 322 |
+
- Quark Netdisk: https://pan.quark.cn/s/3ee3d991d64b
|
| 323 |
+
|
| 324 |
+
After downloading the model, extract it and place the entire directory in `.\MoneyPrinterTurbo\models`,
|
| 325 |
+
The final file path should look like this: `.\MoneyPrinterTurbo\models\whisper-large-v3`
|
| 326 |
+
|
| 327 |
+
```
|
| 328 |
+
MoneyPrinterTurbo
|
| 329 |
+
├─models
|
| 330 |
+
│ └─whisper-large-v3
|
| 331 |
+
│ config.json
|
| 332 |
+
│ model.bin
|
| 333 |
+
│ preprocessor_config.json
|
| 334 |
+
│ tokenizer.json
|
| 335 |
+
│ vocabulary.json
|
| 336 |
+
```
|
| 337 |
+
|
| 338 |
+
## Background Music 🎵
|
| 339 |
+
|
| 340 |
+
Background music for videos is located in the project's `resource/songs` directory.
|
| 341 |
+
|
| 342 |
+
> The current project includes some default music from YouTube videos. If there are copyright issues, please delete
|
| 343 |
+
> them.
|
| 344 |
+
|
| 345 |
+
## Subtitle Fonts 🅰
|
| 346 |
+
|
| 347 |
+
Fonts for rendering video subtitles are located in the project's `resource/fonts` directory, and you can also add your
|
| 348 |
+
own fonts.
|
| 349 |
+
|
| 350 |
+
## Common Questions 🤔
|
| 351 |
+
|
| 352 |
+
### ❓RuntimeError: No ffmpeg exe could be found
|
| 353 |
+
|
| 354 |
+
Normally, ffmpeg will be automatically downloaded and detected.
|
| 355 |
+
However, if your environment has issues preventing automatic downloads, you may encounter the following error:
|
| 356 |
+
|
| 357 |
+
```
|
| 358 |
+
RuntimeError: No ffmpeg exe could be found.
|
| 359 |
+
Install ffmpeg on your system, or set the IMAGEIO_FFMPEG_EXE environment variable.
|
| 360 |
+
```
|
| 361 |
+
|
| 362 |
+
In this case, you can download ffmpeg from https://www.gyan.dev/ffmpeg/builds/, unzip it, and set `ffmpeg_path` to your
|
| 363 |
+
actual installation path.
|
| 364 |
+
|
| 365 |
+
```toml
|
| 366 |
+
[app]
|
| 367 |
+
# Please set according to your actual path, note that Windows path separators are \\
|
| 368 |
+
ffmpeg_path = "C:\\Users\\harry\\Downloads\\ffmpeg.exe"
|
| 369 |
+
```
|
| 370 |
+
|
| 371 |
+
### ❓ImageMagick is not installed on your computer
|
| 372 |
+
|
| 373 |
+
[issue 33](https://github.com/harry0703/MoneyPrinterTurbo/issues/33)
|
| 374 |
+
|
| 375 |
+
1. Follow the `example configuration` provided `download address` to
|
| 376 |
+
install https://imagemagick.org/archive/binaries/ImageMagick-7.1.1-30-Q16-x64-static.exe, using the static library
|
| 377 |
+
2. Do not install in a path with Chinese characters to avoid unpredictable issues
|
| 378 |
+
|
| 379 |
+
[issue 54](https://github.com/harry0703/MoneyPrinterTurbo/issues/54#issuecomment-2017842022)
|
| 380 |
+
|
| 381 |
+
For Linux systems, you can manually install it, refer to https://cn.linux-console.net/?p=16978
|
| 382 |
+
|
| 383 |
+
Thanks to [@wangwenqiao666](https://github.com/wangwenqiao666) for their research and exploration
|
| 384 |
+
|
| 385 |
+
### ❓ImageMagick's security policy prevents operations related to temporary file @/tmp/tmpur5hyyto.txt
|
| 386 |
+
|
| 387 |
+
You can find these policies in ImageMagick's configuration file policy.xml.
|
| 388 |
+
This file is usually located in /etc/ImageMagick-`X`/ or a similar location in the ImageMagick installation directory.
|
| 389 |
+
Modify the entry containing `pattern="@"`, change `rights="none"` to `rights="read|write"` to allow read and write operations on files.
|
| 390 |
+
|
| 391 |
+
### ❓OSError: [Errno 24] Too many open files
|
| 392 |
+
|
| 393 |
+
This issue is caused by the system's limit on the number of open files. You can solve it by modifying the system's file open limit.
|
| 394 |
+
|
| 395 |
+
Check the current limit:
|
| 396 |
+
|
| 397 |
+
```shell
|
| 398 |
+
ulimit -n
|
| 399 |
+
```
|
| 400 |
+
|
| 401 |
+
If it's too low, you can increase it, for example:
|
| 402 |
+
|
| 403 |
+
```shell
|
| 404 |
+
ulimit -n 10240
|
| 405 |
+
```
|
| 406 |
+
|
| 407 |
+
### ❓Whisper model download failed, with the following error
|
| 408 |
+
|
| 409 |
+
LocalEntryNotfoundEror: Cannot find an appropriate cached snapshotfolderfor the specified revision on the local disk and
|
| 410 |
+
outgoing trafic has been disabled.
|
| 411 |
+
To enablerepo look-ups and downloads online, pass 'local files only=False' as input.
|
| 412 |
+
|
| 413 |
+
or
|
| 414 |
+
|
| 415 |
+
An error occurred while synchronizing the model Systran/faster-whisper-large-v3 from the Hugging Face Hub:
|
| 416 |
+
An error happened while trying to locate the files on the Hub and we cannot find the appropriate snapshot folder for the
|
| 417 |
+
specified revision on the local disk. Please check your internet connection and try again.
|
| 418 |
+
Trying to load the model directly from the local cache, if it exists.
|
| 419 |
+
|
| 420 |
+
Solution: [Click to see how to manually download the model from netdisk](#subtitle-generation-)
|
| 421 |
+
|
| 422 |
+
## Feedback & Suggestions 📢
|
| 423 |
+
|
| 424 |
+
- You can submit an [issue](https://github.com/harry0703/MoneyPrinterTurbo/issues) or
|
| 425 |
+
a [pull request](https://github.com/harry0703/MoneyPrinterTurbo/pulls).
|
| 426 |
+
|
| 427 |
+
## License 📝
|
| 428 |
+
|
| 429 |
+
Click to view the [`LICENSE`](LICENSE) file
|
| 430 |
+
|
| 431 |
+
## Star History
|
| 432 |
+
|
| 433 |
+
[](https://star-history.com/#harry0703/MoneyPrinterTurbo&Date)
|
README.md
CHANGED
|
@@ -1,12 +1,419 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
-
emoji:
|
| 4 |
colorFrom: indigo
|
| 5 |
-
colorTo:
|
| 6 |
sdk: docker
|
| 7 |
-
|
| 8 |
-
license: mit
|
| 9 |
-
short_description: money printer turbo (Short Form Video)
|
| 10 |
---
|
| 11 |
|
| 12 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: MoneyPrinterTurbo Swarm
|
| 3 |
+
emoji: 🤖🎬
|
| 4 |
colorFrom: indigo
|
| 5 |
+
colorTo: green
|
| 6 |
sdk: docker
|
| 7 |
+
app_port: 7860
|
|
|
|
|
|
|
| 8 |
---
|
| 9 |
|
| 10 |
+
<div align="center">
|
| 11 |
+
<h1 align="center">MoneyPrinterTurbo 💸</h1>
|
| 12 |
+
|
| 13 |
+
<p align="center">
|
| 14 |
+
<a href="https://github.com/harry0703/MoneyPrinterTurbo/stargazers"><img src="https://img.shields.io/github/stars/harry0703/MoneyPrinterTurbo.svg?style=for-the-badge" alt="Stargazers"></a>
|
| 15 |
+
<a href="https://github.com/harry0703/MoneyPrinterTurbo/issues"><img src="https://img.shields.io/github/issues/harry0703/MoneyPrinterTurbo.svg?style=for-the-badge" alt="Issues"></a>
|
| 16 |
+
<a href="https://github.com/harry0703/MoneyPrinterTurbo/network/members"><img src="https://img.shields.io/github/forks/harry0703/MoneyPrinterTurbo.svg?style=for-the-badge" alt="Forks"></a>
|
| 17 |
+
<a href="https://github.com/harry0703/MoneyPrinterTurbo/blob/main/LICENSE"><img src="https://img.shields.io/github/license/harry0703/MoneyPrinterTurbo.svg?style=for-the-badge" alt="License"></a>
|
| 18 |
+
</p>
|
| 19 |
+
<br>
|
| 20 |
+
<h3>简体中文 | <a href="README-en.md">English</a> | <a href="README-ar.md">العربية</a></h3>
|
| 21 |
+
<div align="center">
|
| 22 |
+
<a href="https://trendshift.io/repositories/8731" target="_blank"><img src="https://trendshift.io/api/badge/repositories/8731" alt="harry0703%2FMoneyPrinterTurbo | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
| 23 |
+
</div>
|
| 24 |
+
|
| 25 |
+
<br>
|
| 26 |
+
只需提供一个视频 <b>主题</b> 或 <b>关键词</b> ,就可以全自动生成视频文案、视频素材、视频字幕、视频背景音乐,然后合成一个高清的短视频。
|
| 27 |
+
<br>
|
| 28 |
+
|
| 29 |
+
<p align="center">
|
| 30 |
+
<sub>
|
| 31 |
+
感谢 <a href="https://aihubmix.com/?aff=CEve">AIHubMix</a> 对本项目的赞助。AIHubMix 深度适配 OpenAI、Claude、Gemini、DeepSeek、智谱、千问等全球顶级最新模型,一站式快速接入 GPT-5.5、deepseek-v4-flash 等 700+ 模型(含多个免费模型),提供企业级生产稳定性保障。
|
| 32 |
+
</sub>
|
| 33 |
+
</p>
|
| 34 |
+
|
| 35 |
+
<h4>Web界面</h4>
|
| 36 |
+
|
| 37 |
+

|
| 38 |
+
|
| 39 |
+
<h4>API界面</h4>
|
| 40 |
+
|
| 41 |
+

|
| 42 |
+
|
| 43 |
+
</div>
|
| 44 |
+
|
| 45 |
+
## 功能特性 🎯
|
| 46 |
+
|
| 47 |
+
- [x] 完整的 **MVC架构**,代码 **结构清晰**,易于维护,支持 `API` 和 `Web界面`
|
| 48 |
+
- [x] 支持视频文案 **AI自动生成**,也可以**自定义文案**
|
| 49 |
+
- [x] 支持多种 **高清视频** 尺寸
|
| 50 |
+
- [x] 竖屏 9:16,`1080x1920`
|
| 51 |
+
- [x] 横屏 16:9,`1920x1080`
|
| 52 |
+
- [x] 支持 **批量视频生成**,可以一次生成多个视频,然后选择一个最满意的
|
| 53 |
+
- [x] 支持 **视频片段时长** 设置,方便调节素材切换频率
|
| 54 |
+
- [x] 支持 **中文** 和 **英文** 视频文案
|
| 55 |
+
- [x] 支持 **多种语音** 合成,可 **实时试听** 效果
|
| 56 |
+
- [x] 支持 **字幕生成**,可以调整 `字体`、`位置`、`颜色`、`大小`,同时支持`字幕描边`设置
|
| 57 |
+
- [x] 支持 **背景音乐**,随机或者指定音乐文件,可设置`背景音乐音量`
|
| 58 |
+
- [x] 视频素材来源 **高清**,而且 **无版权**,也可以使用自己的 **本地素材**
|
| 59 |
+
- [x] 支持 **OpenAI**、**AIHubMix**、**Moonshot**、**Azure**、**gpt4free**、**one-api**、**通义千问**、**Google Gemini**、**Ollama**、**DeepSeek**、**MiniMax**、 **文心一言**, **Pollinations**、**ModelScope** 等多种模型接入
|
| 60 |
+
|
| 61 |
+
## 视频演示 📺
|
| 62 |
+
|
| 63 |
+
### 竖屏 9:16
|
| 64 |
+
|
| 65 |
+
<table>
|
| 66 |
+
<thead>
|
| 67 |
+
<tr>
|
| 68 |
+
<th align="center"><g-emoji class="g-emoji" alias="arrow_forward">▶️</g-emoji> 《如何增加生活的乐趣》</th>
|
| 69 |
+
<th align="center"><g-emoji class="g-emoji" alias="arrow_forward">▶️</g-emoji> 《金钱的作用》<br>更真实的合成声音</th>
|
| 70 |
+
<th align="center"><g-emoji class="g-emoji" alias="arrow_forward">▶️</g-emoji> 《生命的意义是什么》</th>
|
| 71 |
+
</tr>
|
| 72 |
+
</thead>
|
| 73 |
+
<tbody>
|
| 74 |
+
<tr>
|
| 75 |
+
<td align="center"><video src="https://github.com/harry0703/MoneyPrinterTurbo/assets/4928832/a84d33d5-27a2-4aba-8fd0-9fb2bd91c6a6"></video></td>
|
| 76 |
+
<td align="center"><video src="https://github.com/harry0703/MoneyPrinterTurbo/assets/4928832/af2f3b0b-002e-49fe-b161-18ba91c055e8"></video></td>
|
| 77 |
+
<td align="center"><video src="https://github.com/harry0703/MoneyPrinterTurbo/assets/4928832/112c9564-d52b-4472-99ad-970b75f66476"></video></td>
|
| 78 |
+
</tr>
|
| 79 |
+
</tbody>
|
| 80 |
+
</table>
|
| 81 |
+
|
| 82 |
+
### 横屏 16:9
|
| 83 |
+
|
| 84 |
+
<table>
|
| 85 |
+
<thead>
|
| 86 |
+
<tr>
|
| 87 |
+
<th align="center"><g-emoji class="g-emoji" alias="arrow_forward">▶️</g-emoji>《生命的意义是什么》</th>
|
| 88 |
+
<th align="center"><g-emoji class="g-emoji" alias="arrow_forward">▶️</g-emoji>《为什么要运动》</th>
|
| 89 |
+
</tr>
|
| 90 |
+
</thead>
|
| 91 |
+
<tbody>
|
| 92 |
+
<tr>
|
| 93 |
+
<td align="center"><video src="https://github.com/harry0703/MoneyPrinterTurbo/assets/4928832/346ebb15-c55f-47a9-a653-114f08bb8073"></video></td>
|
| 94 |
+
<td align="center"><video src="https://github.com/harry0703/MoneyPrinterTurbo/assets/4928832/271f2fae-8283-44a0-8aa0-0ed8f9a6fa87"></video></td>
|
| 95 |
+
</tr>
|
| 96 |
+
</tbody>
|
| 97 |
+
</table>
|
| 98 |
+
|
| 99 |
+
## 配置要求 📦
|
| 100 |
+
|
| 101 |
+
- 建议系统:Windows 10 或 MacOS 11.0 以上,或主流 Linux 发行版
|
| 102 |
+
- GPU 不是必需项,但如果你希望本地转录、更快的视频处理或更顺畅的批量生成体验,建议使用带显存的独立显卡
|
| 103 |
+
|
| 104 |
+
| 项目 | 最低配置 | 推荐配置 | 理想配置 |
|
| 105 |
+
| ---- | -------- | --------------- | --------------- |
|
| 106 |
+
| CPU | 4 核 | 6 到 8 核 | 8 核及以上 |
|
| 107 |
+
| RAM | 4 GB | 8 GB | 16 GB 及以上 |
|
| 108 |
+
| GPU | 非必须 | 4 GB 显存及以上 | 8 GB 显存及以上 |
|
| 109 |
+
|
| 110 |
+
- 如果你主要依赖云端 LLM、云端 TTS 和在线素材源,CPU 与内存比 GPU 更重要
|
| 111 |
+
- 如果你启用 `faster-whisper`、批量生成或更重的本地处理链路,GPU 会明显提升速度
|
| 112 |
+
|
| 113 |
+
## 快速开始 🚀
|
| 114 |
+
|
| 115 |
+
### 推荐使用方式
|
| 116 |
+
|
| 117 |
+
- Windows 用户:优先使用一键启动包,适合快速体验
|
| 118 |
+
- MacOS / Linux 用户:优先使用 `uv sync --frozen` 进行本地部署
|
| 119 |
+
- 想要隔离运行环境:优先使用 Docker 部署
|
| 120 |
+
|
| 121 |
+
### 在 Google Colab 中运行
|
| 122 |
+
|
| 123 |
+
免去本地环境配置,点击直接在 Google Colab 中快速体验 MoneyPrinterTurbo
|
| 124 |
+
|
| 125 |
+
[](https://colab.research.google.com/github/harry0703/MoneyPrinterTurbo/blob/main/docs/MoneyPrinterTurbo.ipynb)
|
| 126 |
+
|
| 127 |
+
### Windows一键启动包
|
| 128 |
+
|
| 129 |
+
下载一键启动包,解压直接使用(路径不要有 **中文**、**特殊字符**、**空格**)
|
| 130 |
+
当前提供的安装包仍是 `v1.2.6` 的旧打包版本,建议下载后先执行 `update.bat` 更新到最新代码。
|
| 131 |
+
|
| 132 |
+
- 百度网盘(v1.2.6): https://pan.baidu.com/s/1wg0UaIyXpO3SqIpaq790SQ?pwd=sbqx 提取码: sbqx
|
| 133 |
+
- Google Drive (v1.2.6): https://drive.google.com/file/d/1HsbzfT7XunkrCrHw5ncUjFX8XX4zAuUh/view?usp=sharing
|
| 134 |
+
|
| 135 |
+
下载后,建议先**双击执行** `update.bat` 更新到**最新代码**,然后双击 `start.bat` 启动
|
| 136 |
+
|
| 137 |
+
启动后,会自动打开浏览器(如果打开是空白,建议换成 **Chrome** 或者 **Edge** 打开)
|
| 138 |
+
|
| 139 |
+
## 安装部署 📥
|
| 140 |
+
|
| 141 |
+
### 前提条件
|
| 142 |
+
|
| 143 |
+
- 尽量不要使用 **中文路径**,避免出现一些无法预料的问题
|
| 144 |
+
- 请确保你的 **网络** 是正常的,VPN需要打开`全局流量`模式
|
| 145 |
+
|
| 146 |
+
#### ① 克隆代码
|
| 147 |
+
|
| 148 |
+
```shell
|
| 149 |
+
git clone https://github.com/harry0703/MoneyPrinterTurbo.git
|
| 150 |
+
```
|
| 151 |
+
|
| 152 |
+
#### ② 修改配置文件(可选,建议启动后也可以在 WebUI 里面配置)
|
| 153 |
+
|
| 154 |
+
- 将 `config.example.toml` 文件复制一份,命名为 `config.toml`
|
| 155 |
+
- 按照 `config.toml` 文件中的说明,配置好 `pexels_api_keys` 和 `llm_provider`,并根据 llm_provider 对应的服务商,配置相关的
|
| 156 |
+
API Key
|
| 157 |
+
- 如果希望使用推荐的大模型平台,也可以将 `llm_provider` 设置为 `aihubmix`,并填写对应的 API Key。
|
| 158 |
+
|
| 159 |
+
### Docker部署 🐳
|
| 160 |
+
|
| 161 |
+
#### ① 启动Docker
|
| 162 |
+
|
| 163 |
+
如果未安装 Docker,请先安装 https://www.docker.com/products/docker-desktop/
|
| 164 |
+
|
| 165 |
+
如果是Windows系统,请参考微软的文档:
|
| 166 |
+
|
| 167 |
+
1. https://learn.microsoft.com/zh-cn/windows/wsl/install
|
| 168 |
+
2. https://learn.microsoft.com/zh-cn/windows/wsl/tutorials/wsl-containers
|
| 169 |
+
|
| 170 |
+
```shell
|
| 171 |
+
cd MoneyPrinterTurbo
|
| 172 |
+
docker-compose up
|
| 173 |
+
```
|
| 174 |
+
|
| 175 |
+
> 注意:最新版的docker安装时会自动以插件的形式安装docker compose,启动命令调整为docker compose up
|
| 176 |
+
|
| 177 |
+
#### ② 访问Web界面
|
| 178 |
+
|
| 179 |
+
打开浏览器,访问 http://127.0.0.1:8501
|
| 180 |
+
|
| 181 |
+
#### ③ 访问API文档
|
| 182 |
+
|
| 183 |
+
打开浏览器,访问 http://0.0.0.0:8080/docs 或者 http://0.0.0.0:8080/redoc
|
| 184 |
+
|
| 185 |
+
### 手动部署 📦
|
| 186 |
+
|
| 187 |
+
> 视频教程
|
| 188 |
+
|
| 189 |
+
- 完整的使用演示:https://v.douyin.com/iFhnwsKY/
|
| 190 |
+
- 如何在Windows上部署:https://v.douyin.com/iFyjoW3M
|
| 191 |
+
|
| 192 |
+
#### ① 创建虚拟环境
|
| 193 |
+
|
| 194 |
+
推荐使用 [uv](https://docs.astral.sh/uv/) 管理 Python 环境和依赖,默认使用 Python `3.11`
|
| 195 |
+
|
| 196 |
+
```shell
|
| 197 |
+
git clone https://github.com/harry0703/MoneyPrinterTurbo.git
|
| 198 |
+
cd MoneyPrinterTurbo
|
| 199 |
+
uv python install 3.11
|
| 200 |
+
uv sync --frozen
|
| 201 |
+
```
|
| 202 |
+
|
| 203 |
+
如果你暂时不使用 `uv`,也可以继续使用 `venv + pip`
|
| 204 |
+
|
| 205 |
+
```shell
|
| 206 |
+
python3.11 -m venv .venv
|
| 207 |
+
source .venv/bin/activate
|
| 208 |
+
pip install -r requirements.txt
|
| 209 |
+
```
|
| 210 |
+
|
| 211 |
+
说明:
|
| 212 |
+
|
| 213 |
+
- `pyproject.toml` 是主依赖定义文件
|
| 214 |
+
- `uv.lock` 是锁文件,建议默认执行 `uv sync --frozen`
|
| 215 |
+
- `requirements.txt` 仅保留给旧的 `pip` 安装方式兼容使用
|
| 216 |
+
|
| 217 |
+
#### ② 安装好 ImageMagick
|
| 218 |
+
|
| 219 |
+
- Windows:
|
| 220 |
+
- 下载 https://imagemagick.org/script/download.php 选择Windows版本,切记一定要选择 **静态库** 版本,比如
|
| 221 |
+
ImageMagick-7.1.1-32-Q16-x64-**static**.exe
|
| 222 |
+
- 安装下载好的 ImageMagick,**注意不要修改安装路径**
|
| 223 |
+
- 修改 `配置文件 config.toml` 中的 `imagemagick_path` 为你的 **实际安装路径**
|
| 224 |
+
|
| 225 |
+
- MacOS:
|
| 226 |
+
```shell
|
| 227 |
+
brew install imagemagick
|
| 228 |
+
```
|
| 229 |
+
- Ubuntu
|
| 230 |
+
```shell
|
| 231 |
+
sudo apt-get install imagemagick
|
| 232 |
+
```
|
| 233 |
+
- CentOS
|
| 234 |
+
```shell
|
| 235 |
+
sudo yum install ImageMagick
|
| 236 |
+
```
|
| 237 |
+
|
| 238 |
+
#### ③ 启动Web界面 🌐
|
| 239 |
+
|
| 240 |
+
注意需要到 MoneyPrinterTurbo 项目 `根目录` 下执行以下命令
|
| 241 |
+
|
| 242 |
+
###### Windows
|
| 243 |
+
|
| 244 |
+
```powershell
|
| 245 |
+
.\webui.bat
|
| 246 |
+
```
|
| 247 |
+
|
| 248 |
+
在 CMD 中也可以执行 `webui.bat`。
|
| 249 |
+
`webui.bat` 会优先使用项目 `.venv` 或一键包内置 Python;如果没有找到项目 Python,但已安装 `uv`,会自动切换为 `uv run streamlit`。
|
| 250 |
+
如需允许局域网内其他设备访问 WebUI,可以先执行 `set MPT_WEBUI_HOST=0.0.0.0`,再运行 `webui.bat`。
|
| 251 |
+
|
| 252 |
+
###### MacOS or Linux
|
| 253 |
+
|
| 254 |
+
```shell
|
| 255 |
+
uv run streamlit run ./webui/Main.py --browser.gatherUsageStats=False
|
| 256 |
+
```
|
| 257 |
+
|
| 258 |
+
如果你已经手动激活了虚拟环境,也可以直接执行:
|
| 259 |
+
|
| 260 |
+
```shell
|
| 261 |
+
sh webui.sh
|
| 262 |
+
```
|
| 263 |
+
|
| 264 |
+
启动后,会自动打开浏览器(如果打开是空白,建议换成 **Chrome** 或者 **Edge** 打开)
|
| 265 |
+
|
| 266 |
+
#### ④ 启动API服务 🚀
|
| 267 |
+
|
| 268 |
+
```shell
|
| 269 |
+
uv run python main.py
|
| 270 |
+
```
|
| 271 |
+
|
| 272 |
+
如果你已经手动激活了虚拟环境,也可以直接执行:
|
| 273 |
+
|
| 274 |
+
```shell
|
| 275 |
+
python main.py
|
| 276 |
+
```
|
| 277 |
+
|
| 278 |
+
## 特别感谢 🙏
|
| 279 |
+
|
| 280 |
+
由于该项目的 **部署** 和 **使用**,对于一些小白用户来说,还是 **有一定的门槛**,在此特别感谢
|
| 281 |
+
**录咖(AI智能 多媒体服务平台)** 网站基于该项目,提供的免费`AI视频生成器`服务,可以不用部署,直接在线使用,非常方便。
|
| 282 |
+
|
| 283 |
+
- 中文版:https://reccloud.cn
|
| 284 |
+
- 英文版:https://reccloud.com
|
| 285 |
+
|
| 286 |
+

|
| 287 |
+
|
| 288 |
+
## 感谢赞助 🙏
|
| 289 |
+
|
| 290 |
+
感谢佐糖 https://picwish.cn 对该项目的支持和赞助,使得该项目能够持续的更新和维护。
|
| 291 |
+
|
| 292 |
+
佐糖专注于**图像处理领域**,提供丰富的**图像处理工具**,将复杂操作极致简化,真正实现让图像处理更简单。
|
| 293 |
+
|
| 294 |
+

|
| 295 |
+
|
| 296 |
+
启动后,可以查看 `API文档` http://127.0.0.1:8080/docs 或者 http://127.0.0.1:8080/redoc 直接在线调试接口,快速体验。
|
| 297 |
+
|
| 298 |
+
## 语音合成 🗣
|
| 299 |
+
|
| 300 |
+
所有支持的声音列表,可以查看:[声音列表](./docs/voice-list.txt)
|
| 301 |
+
|
| 302 |
+
2024-04-16 v1.1.2 新增了9种Azure的语音合成声音,需要配置API KEY,该声音合成的更加真实。
|
| 303 |
+
|
| 304 |
+
## 字幕生成 📜
|
| 305 |
+
|
| 306 |
+
当前支持2种字幕生成方式:
|
| 307 |
+
|
| 308 |
+
- **edge**: 生成`速度快`,性能更好,对电脑配置没有要求,但是质量可能不稳定
|
| 309 |
+
- **whisper**: 生成`速度慢`,性能较差,对电脑配置有一定要求,但是`质量更可靠`。
|
| 310 |
+
|
| 311 |
+
可以修改 `config.toml` 配置文件中的 `subtitle_provider` 进行切换
|
| 312 |
+
|
| 313 |
+
建议使用 `edge` 模式,如果生成的字幕质量不好,再切换到 `whisper` 模式
|
| 314 |
+
|
| 315 |
+
> 注意:
|
| 316 |
+
|
| 317 |
+
1. whisper 模式下需要到 HuggingFace 下载一个模型文件,大约 3GB 左右,请确保网络通畅
|
| 318 |
+
2. 如果留空,表示不生成字幕。
|
| 319 |
+
|
| 320 |
+
> 由于国内无法访问 HuggingFace,可以使用以下方法下载 `whisper-large-v3` 的模型文件
|
| 321 |
+
|
| 322 |
+
下载地址:
|
| 323 |
+
|
| 324 |
+
- 百度网盘: https://pan.baidu.com/s/11h3Q6tsDtjQKTjUu3sc5cA?pwd=xjs9
|
| 325 |
+
- 夸克网盘:https://pan.quark.cn/s/3ee3d991d64b
|
| 326 |
+
|
| 327 |
+
模型下载后解压,整个目录放到 `.\MoneyPrinterTurbo\models` 里面,
|
| 328 |
+
最终的文件路径应该是这样: `.\MoneyPrinterTurbo\models\whisper-large-v3`
|
| 329 |
+
|
| 330 |
+
```
|
| 331 |
+
MoneyPrinterTurbo
|
| 332 |
+
├─models
|
| 333 |
+
│ └─whisper-large-v3
|
| 334 |
+
│ config.json
|
| 335 |
+
│ model.bin
|
| 336 |
+
│ preprocessor_config.json
|
| 337 |
+
│ tokenizer.json
|
| 338 |
+
│ vocabulary.json
|
| 339 |
+
```
|
| 340 |
+
|
| 341 |
+
## 背景音乐 🎵
|
| 342 |
+
|
| 343 |
+
用于视频的背景音乐,位于项目的 `resource/songs` 目录下。
|
| 344 |
+
|
| 345 |
+
> 当前项目里面放了一些默认的音乐,来自于 YouTube 视频,如有侵权,请删除。
|
| 346 |
+
|
| 347 |
+
## 字幕字体 🅰
|
| 348 |
+
|
| 349 |
+
用于视频字幕的渲染,位于项目的 `resource/fonts` 目录下,你也可以放进去自己的字体。
|
| 350 |
+
|
| 351 |
+
## 常见问题 🤔
|
| 352 |
+
|
| 353 |
+
### ❓RuntimeError: No ffmpeg exe could be found
|
| 354 |
+
|
| 355 |
+
通常情况下,ffmpeg 会被自动下载,并且会被自动检测到。
|
| 356 |
+
但是如果你的环境有问题,无法自动下载,可能会遇到如下错误:
|
| 357 |
+
|
| 358 |
+
```
|
| 359 |
+
RuntimeError: No ffmpeg exe could be found.
|
| 360 |
+
Install ffmpeg on your system, or set the IMAGEIO_FFMPEG_EXE environment variable.
|
| 361 |
+
```
|
| 362 |
+
|
| 363 |
+
此时你可以从 https://www.gyan.dev/ffmpeg/builds/ 下载ffmpeg,解压后,设置 `ffmpeg_path` 为你的实际安装路径即可。
|
| 364 |
+
|
| 365 |
+
```toml
|
| 366 |
+
[app]
|
| 367 |
+
# 请根据你的实际路径设置,注意 Windows 路径分隔符为 \\
|
| 368 |
+
ffmpeg_path = "C:\\Users\\harry\\Downloads\\ffmpeg.exe"
|
| 369 |
+
```
|
| 370 |
+
|
| 371 |
+
### ❓ImageMagick的安全策略阻止了与临时文件@/tmp/tmpur5hyyto.txt相关的操作
|
| 372 |
+
|
| 373 |
+
可以在ImageMagick的配置文件policy.xml中找到这些策略。
|
| 374 |
+
这个文件通常位于 /etc/ImageMagick-`X`/ 或 ImageMagick 安装目录的类似位置。
|
| 375 |
+
修改包含`pattern="@"`的条目,将`rights="none"`更改为`rights="read|write"`以允许对文件的读写操作。
|
| 376 |
+
|
| 377 |
+
### ❓OSError: [Errno 24] Too many open files
|
| 378 |
+
|
| 379 |
+
这个问题是由于系统打开文件数限制导致的,可以通过修改系统的文件打开数限制来解决。
|
| 380 |
+
|
| 381 |
+
查看当前限制
|
| 382 |
+
|
| 383 |
+
```shell
|
| 384 |
+
ulimit -n
|
| 385 |
+
```
|
| 386 |
+
|
| 387 |
+
如果过低,可以调高一些,比如
|
| 388 |
+
|
| 389 |
+
```shell
|
| 390 |
+
ulimit -n 10240
|
| 391 |
+
```
|
| 392 |
+
|
| 393 |
+
### ❓Whisper 模型下载失败,出现如下错误
|
| 394 |
+
|
| 395 |
+
LocalEntryNotfoundEror: Cannot find an appropriate cached snapshotfolderfor the specified revision on the local disk and
|
| 396 |
+
outgoing trafic has been disabled.
|
| 397 |
+
To enablerepo look-ups and downloads online, pass 'local files only=False' as input.
|
| 398 |
+
|
| 399 |
+
或者
|
| 400 |
+
|
| 401 |
+
An error occurred while synchronizing the model Systran/faster-whisper-large-v3 from the Hugging Face Hub:
|
| 402 |
+
An error happened while trying to locate the files on the Hub and we cannot find the appropriate snapshot folder for the
|
| 403 |
+
specified revision on the local disk. Please check your internet connection and try again.
|
| 404 |
+
Trying to load the model directly from the local cache, if it exists.
|
| 405 |
+
|
| 406 |
+
解决方法:[点击查看如何从网盘手动下载模型](#%E5%AD%97%E5%B9%95%E7%94%9F%E6%88%90-)
|
| 407 |
+
|
| 408 |
+
## 反馈建议 📢
|
| 409 |
+
|
| 410 |
+
- 可以提交 [issue](https://github.com/harry0703/MoneyPrinterTurbo/issues)
|
| 411 |
+
或者 [pull request](https://github.com/harry0703/MoneyPrinterTurbo/pulls)。
|
| 412 |
+
|
| 413 |
+
## 许可证 📝
|
| 414 |
+
|
| 415 |
+
点击查看 [`LICENSE`](LICENSE) 文件
|
| 416 |
+
|
| 417 |
+
## Star History
|
| 418 |
+
|
| 419 |
+
[](https://star-history.com/#harry0703/MoneyPrinterTurbo&Date)
|
app/__init__.py
ADDED
|
File without changes
|
app/asgi.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Application implementation - ASGI."""
|
| 2 |
+
|
| 3 |
+
import os
|
| 4 |
+
|
| 5 |
+
from fastapi import FastAPI, Request
|
| 6 |
+
from fastapi.exceptions import RequestValidationError
|
| 7 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 8 |
+
from fastapi.responses import JSONResponse
|
| 9 |
+
from fastapi.staticfiles import StaticFiles
|
| 10 |
+
from loguru import logger
|
| 11 |
+
|
| 12 |
+
from app.config import config
|
| 13 |
+
from app.models.exception import HttpException
|
| 14 |
+
from app.router import root_api_router
|
| 15 |
+
from app.utils import utils
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def exception_handler(request: Request, e: HttpException):
|
| 19 |
+
return JSONResponse(
|
| 20 |
+
status_code=e.status_code,
|
| 21 |
+
content=utils.get_response(e.status_code, e.data, e.message),
|
| 22 |
+
)
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def validation_exception_handler(request: Request, e: RequestValidationError):
|
| 26 |
+
return JSONResponse(
|
| 27 |
+
status_code=400,
|
| 28 |
+
content=utils.get_response(
|
| 29 |
+
status=400, data=e.errors(), message="field required"
|
| 30 |
+
),
|
| 31 |
+
)
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def get_application() -> FastAPI:
|
| 35 |
+
"""Initialize FastAPI application.
|
| 36 |
+
|
| 37 |
+
Returns:
|
| 38 |
+
FastAPI: Application object instance.
|
| 39 |
+
|
| 40 |
+
"""
|
| 41 |
+
instance = FastAPI(
|
| 42 |
+
title=config.project_name,
|
| 43 |
+
description=config.project_description,
|
| 44 |
+
version=config.project_version,
|
| 45 |
+
debug=False,
|
| 46 |
+
)
|
| 47 |
+
instance.include_router(root_api_router)
|
| 48 |
+
instance.add_exception_handler(HttpException, exception_handler)
|
| 49 |
+
instance.add_exception_handler(RequestValidationError, validation_exception_handler)
|
| 50 |
+
return instance
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
app = get_application()
|
| 54 |
+
|
| 55 |
+
# Configures the CORS middleware for the FastAPI app
|
| 56 |
+
cors_allowed_origins_str = os.getenv("CORS_ALLOWED_ORIGINS", "")
|
| 57 |
+
origins = cors_allowed_origins_str.split(",") if cors_allowed_origins_str else ["*"]
|
| 58 |
+
app.add_middleware(
|
| 59 |
+
CORSMiddleware,
|
| 60 |
+
allow_origins=origins,
|
| 61 |
+
allow_credentials=True,
|
| 62 |
+
allow_methods=["*"],
|
| 63 |
+
allow_headers=["*"],
|
| 64 |
+
)
|
| 65 |
+
|
| 66 |
+
task_dir = utils.task_dir()
|
| 67 |
+
app.mount(
|
| 68 |
+
"/tasks", StaticFiles(directory=task_dir, html=True, follow_symlink=True), name=""
|
| 69 |
+
)
|
| 70 |
+
|
| 71 |
+
public_dir = utils.public_dir()
|
| 72 |
+
app.mount("/", StaticFiles(directory=public_dir, html=True), name="")
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
@app.on_event("shutdown")
|
| 76 |
+
def shutdown_event():
|
| 77 |
+
logger.info("shutdown event")
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
@app.on_event("startup")
|
| 81 |
+
def startup_event():
|
| 82 |
+
logger.info("startup event")
|
app/config/__init__.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import sys
|
| 3 |
+
|
| 4 |
+
from loguru import logger
|
| 5 |
+
|
| 6 |
+
from app.config import config
|
| 7 |
+
from app.utils import utils
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def __init_logger():
|
| 11 |
+
# _log_file = utils.storage_dir("logs/server.log")
|
| 12 |
+
_lvl = config.log_level
|
| 13 |
+
root_dir = os.path.dirname(
|
| 14 |
+
os.path.dirname(os.path.dirname(os.path.realpath(__file__)))
|
| 15 |
+
)
|
| 16 |
+
|
| 17 |
+
def format_record(record):
|
| 18 |
+
# 获取日志记录中的文件全路径
|
| 19 |
+
file_path = record["file"].path
|
| 20 |
+
# 将绝对路径转换为相对于项目根目录的路径
|
| 21 |
+
relative_path = os.path.relpath(file_path, root_dir)
|
| 22 |
+
# 更新记录中的文件路径
|
| 23 |
+
record["file"].path = f"./{relative_path}"
|
| 24 |
+
# 返回修改后的格式字符串
|
| 25 |
+
# 您可以根据需要调整这里的格式
|
| 26 |
+
_format = (
|
| 27 |
+
"<green>{time:%Y-%m-%d %H:%M:%S}</> | "
|
| 28 |
+
+ "<level>{level}</> | "
|
| 29 |
+
+ '"{file.path}:{line}":<blue> {function}</> '
|
| 30 |
+
+ "- <level>{message}</>"
|
| 31 |
+
+ "\n"
|
| 32 |
+
)
|
| 33 |
+
return _format
|
| 34 |
+
|
| 35 |
+
logger.remove()
|
| 36 |
+
|
| 37 |
+
logger.add(
|
| 38 |
+
sys.stdout,
|
| 39 |
+
level=_lvl,
|
| 40 |
+
format=format_record,
|
| 41 |
+
colorize=True,
|
| 42 |
+
)
|
| 43 |
+
|
| 44 |
+
# logger.add(
|
| 45 |
+
# _log_file,
|
| 46 |
+
# level=_lvl,
|
| 47 |
+
# format=format_record,
|
| 48 |
+
# rotation="00:00",
|
| 49 |
+
# retention="3 days",
|
| 50 |
+
# backtrace=True,
|
| 51 |
+
# diagnose=True,
|
| 52 |
+
# enqueue=True,
|
| 53 |
+
# )
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
__init_logger()
|
app/config/config.py
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import shutil
|
| 3 |
+
import socket
|
| 4 |
+
|
| 5 |
+
import toml
|
| 6 |
+
from loguru import logger
|
| 7 |
+
|
| 8 |
+
root_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.realpath(__file__))))
|
| 9 |
+
config_file = f"{root_dir}/config.toml"
|
| 10 |
+
_CONTAINER_CGROUP_MARKERS = ("docker", "containerd", "kubepods", "libpod", "podman")
|
| 11 |
+
_DOCKER_HOST_GATEWAY_NAME = "host.docker.internal"
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def is_running_in_container(
|
| 15 |
+
dockerenv_path: str = "/.dockerenv",
|
| 16 |
+
containerenv_path: str = "/run/.containerenv",
|
| 17 |
+
cgroup_path: str = "/proc/1/cgroup",
|
| 18 |
+
) -> bool:
|
| 19 |
+
"""
|
| 20 |
+
判断当前进程是否运行在容器内。
|
| 21 |
+
|
| 22 |
+
这个判断主要用于 Ollama 默认地址选择:
|
| 23 |
+
- 普通本机运行时,`localhost` 指向用户机器本身;
|
| 24 |
+
- Docker 容器内,`localhost` 指向容器自己,访问宿主机 Ollama
|
| 25 |
+
通常需要使用 `host.docker.internal`。
|
| 26 |
+
|
| 27 |
+
不能只判断 `/proc/1/cgroup` 是否存在,因为普通 Linux 也会有这个文件。
|
| 28 |
+
这里只在检测到明确的容器标记时返回 True,避免误伤非 Docker Linux 用户。
|
| 29 |
+
参数保留为可注入路径,便于单元测试覆盖不同运行环境。
|
| 30 |
+
"""
|
| 31 |
+
if os.path.isfile(dockerenv_path) or os.path.isfile(containerenv_path):
|
| 32 |
+
return True
|
| 33 |
+
|
| 34 |
+
try:
|
| 35 |
+
with open(cgroup_path, mode="r", encoding="utf-8") as fp:
|
| 36 |
+
cgroup_content = fp.read().lower()
|
| 37 |
+
except OSError:
|
| 38 |
+
return False
|
| 39 |
+
|
| 40 |
+
return any(marker in cgroup_content for marker in _CONTAINER_CGROUP_MARKERS)
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def _can_resolve_hostname(hostname: str) -> bool:
|
| 44 |
+
try:
|
| 45 |
+
socket.gethostbyname(hostname)
|
| 46 |
+
except OSError:
|
| 47 |
+
return False
|
| 48 |
+
return True
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def _decode_linux_route_gateway(hex_gateway: str) -> str:
|
| 52 |
+
# /proc/net/route 里的 Gateway 是 16 进制小端序,例如 010011AC 表示
|
| 53 |
+
# 172.17.0.1。这里单独解析,是为了在原生 Linux Docker 没有
|
| 54 |
+
# host.docker.internal DNS 记录时,还能尝试访问容器默认网关上的宿主机。
|
| 55 |
+
if len(hex_gateway) != 8:
|
| 56 |
+
raise ValueError("invalid gateway length")
|
| 57 |
+
|
| 58 |
+
octets = [
|
| 59 |
+
str(int(hex_gateway[index : index + 2], 16))
|
| 60 |
+
for index in range(6, -1, -2)
|
| 61 |
+
]
|
| 62 |
+
return ".".join(octets)
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def get_container_default_gateway_ip(route_path: str = "/proc/net/route") -> str:
|
| 66 |
+
"""
|
| 67 |
+
读取 Linux 容器里的默认网关 IP。
|
| 68 |
+
|
| 69 |
+
Docker Desktop 通常提供 `host.docker.internal`,但原生 Linux Docker
|
| 70 |
+
默认不一定提供这个 DNS 名称。默认网关通常可以作为访问宿主机服务的
|
| 71 |
+
兜底地址;如果用户的 Ollama 只监听 127.0.0.1,则仍需要用户让
|
| 72 |
+
Ollama 监听宿主机网卡或手动配置 `ollama_base_url`。
|
| 73 |
+
"""
|
| 74 |
+
try:
|
| 75 |
+
with open(route_path, mode="r", encoding="utf-8") as fp:
|
| 76 |
+
route_lines = fp.readlines()
|
| 77 |
+
except OSError:
|
| 78 |
+
return ""
|
| 79 |
+
|
| 80 |
+
for line in route_lines[1:]:
|
| 81 |
+
fields = line.strip().split()
|
| 82 |
+
if len(fields) < 3:
|
| 83 |
+
continue
|
| 84 |
+
|
| 85 |
+
destination = fields[1]
|
| 86 |
+
gateway = fields[2]
|
| 87 |
+
if destination != "00000000" or gateway == "00000000":
|
| 88 |
+
continue
|
| 89 |
+
|
| 90 |
+
try:
|
| 91 |
+
return _decode_linux_route_gateway(gateway)
|
| 92 |
+
except ValueError:
|
| 93 |
+
logger.warning(f"invalid container gateway route entry: {line.strip()}")
|
| 94 |
+
return ""
|
| 95 |
+
|
| 96 |
+
return ""
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def get_default_ollama_base_url() -> str:
|
| 100 |
+
"""
|
| 101 |
+
返回 Ollama 的默认 OpenAI-compatible base_url。
|
| 102 |
+
|
| 103 |
+
用户显式配置 `ollama_base_url` 时不会走这里;这里只处理“未配置时的
|
| 104 |
+
最佳默认值”。容器内默认指向宿主机,普通本机运行默认指向 localhost。
|
| 105 |
+
"""
|
| 106 |
+
if not is_running_in_container():
|
| 107 |
+
return "http://localhost:11434/v1"
|
| 108 |
+
|
| 109 |
+
if _can_resolve_hostname(_DOCKER_HOST_GATEWAY_NAME):
|
| 110 |
+
return f"http://{_DOCKER_HOST_GATEWAY_NAME}:11434/v1"
|
| 111 |
+
|
| 112 |
+
gateway_ip = get_container_default_gateway_ip()
|
| 113 |
+
if gateway_ip:
|
| 114 |
+
logger.info(
|
| 115 |
+
"host.docker.internal is not resolvable, fallback to container "
|
| 116 |
+
f"default gateway for Ollama: {gateway_ip}"
|
| 117 |
+
)
|
| 118 |
+
return f"http://{gateway_ip}:11434/v1"
|
| 119 |
+
|
| 120 |
+
logger.warning(
|
| 121 |
+
"failed to resolve host.docker.internal and container default gateway; "
|
| 122 |
+
"fallback to host.docker.internal for Ollama"
|
| 123 |
+
)
|
| 124 |
+
return f"http://{_DOCKER_HOST_GATEWAY_NAME}:11434/v1"
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
def load_config():
|
| 128 |
+
# fix: IsADirectoryError: [Errno 21] Is a directory: '/MoneyPrinterTurbo/config.toml'
|
| 129 |
+
if os.path.isdir(config_file):
|
| 130 |
+
shutil.rmtree(config_file)
|
| 131 |
+
|
| 132 |
+
if not os.path.isfile(config_file):
|
| 133 |
+
example_file = f"{root_dir}/config.example.toml"
|
| 134 |
+
if os.path.isfile(example_file):
|
| 135 |
+
shutil.copyfile(example_file, config_file)
|
| 136 |
+
logger.info("copy config.example.toml to config.toml")
|
| 137 |
+
|
| 138 |
+
logger.info(f"load config from file: {config_file}")
|
| 139 |
+
|
| 140 |
+
try:
|
| 141 |
+
_config_ = toml.load(config_file)
|
| 142 |
+
except Exception as e:
|
| 143 |
+
logger.warning(f"load config failed: {str(e)}, try to load as utf-8-sig")
|
| 144 |
+
with open(config_file, mode="r", encoding="utf-8-sig") as fp:
|
| 145 |
+
_cfg_content = fp.read()
|
| 146 |
+
_config_ = toml.loads(_cfg_content)
|
| 147 |
+
return _config_
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
def save_config():
|
| 151 |
+
with open(config_file, "w", encoding="utf-8") as f:
|
| 152 |
+
_cfg["app"] = app
|
| 153 |
+
_cfg["azure"] = azure
|
| 154 |
+
_cfg["siliconflow"] = siliconflow
|
| 155 |
+
_cfg["ui"] = ui
|
| 156 |
+
f.write(toml.dumps(_cfg))
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
_cfg = load_config()
|
| 160 |
+
app = _cfg.get("app", {})
|
| 161 |
+
whisper = _cfg.get("whisper", {})
|
| 162 |
+
proxy = _cfg.get("proxy", {})
|
| 163 |
+
azure = _cfg.get("azure", {})
|
| 164 |
+
siliconflow = _cfg.get("siliconflow", {})
|
| 165 |
+
ui = _cfg.get(
|
| 166 |
+
"ui",
|
| 167 |
+
{
|
| 168 |
+
"hide_log": False,
|
| 169 |
+
},
|
| 170 |
+
)
|
| 171 |
+
|
| 172 |
+
hostname = socket.gethostname()
|
| 173 |
+
|
| 174 |
+
log_level = _cfg.get("log_level", "DEBUG")
|
| 175 |
+
listen_host = _cfg.get("listen_host", "0.0.0.0")
|
| 176 |
+
listen_port = _cfg.get("listen_port", 8080)
|
| 177 |
+
listen_port = int(os.getenv("PORT", listen_port))
|
| 178 |
+
project_name = _cfg.get("project_name", "MoneyPrinterTurbo")
|
| 179 |
+
project_description = _cfg.get(
|
| 180 |
+
"project_description",
|
| 181 |
+
"<a href='https://github.com/harry0703/MoneyPrinterTurbo'>https://github.com/harry0703/MoneyPrinterTurbo</a>"
|
| 182 |
+
"<br><small>Supported by <a href='https://aihubmix.com/?aff=CEve'>AIHubMix</a></small>",
|
| 183 |
+
)
|
| 184 |
+
project_version = _cfg.get("project_version", "1.2.9")
|
| 185 |
+
reload_debug = False
|
| 186 |
+
|
| 187 |
+
app["redis_host"] = os.getenv(
|
| 188 |
+
"MPT_APP_REDIS_HOST",
|
| 189 |
+
os.getenv("REDIS_HOST", app.get("redis_host", "localhost")),
|
| 190 |
+
)
|
| 191 |
+
|
| 192 |
+
# Overrides from Environment variables for Hugging Face Spaces / Docker deployments
|
| 193 |
+
app["openai_api_key"] = os.getenv("OPENAI_API_KEY", app.get("openai_api_key", ""))
|
| 194 |
+
app["openai_base_url"] = os.getenv("OPENAI_BASE_URL", app.get("openai_base_url", "https://openrouter.ai/api/v1"))
|
| 195 |
+
app["openai_model_name"] = os.getenv("OPENAI_MODEL_NAME", app.get("openai_model_name", "openai/gpt-4o-mini"))
|
| 196 |
+
app["nvidia_api_key"] = os.getenv("NVIDIA_API_KEY", app.get("nvidia_api_key", ""))
|
| 197 |
+
app["nvidia_image_model"] = os.getenv("NVIDIA_IMAGE_MODEL", app.get("nvidia_image_model", "stabilityai/stable-diffusion-xl-base-1.0"))
|
| 198 |
+
|
| 199 |
+
imagemagick_path = app.get("imagemagick_path", "")
|
| 200 |
+
if imagemagick_path and os.path.isfile(imagemagick_path):
|
| 201 |
+
os.environ["IMAGEMAGICK_BINARY"] = imagemagick_path
|
| 202 |
+
|
| 203 |
+
ffmpeg_path = app.get("ffmpeg_path", "")
|
| 204 |
+
if ffmpeg_path and os.path.isfile(ffmpeg_path):
|
| 205 |
+
os.environ["IMAGEIO_FFMPEG_EXE"] = ffmpeg_path
|
| 206 |
+
|
| 207 |
+
logger.info(f"{project_name} v{project_version}")
|
app/controllers/base.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from uuid import uuid4
|
| 2 |
+
|
| 3 |
+
from fastapi import Request
|
| 4 |
+
|
| 5 |
+
from app.config import config
|
| 6 |
+
from app.models.exception import HttpException
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def get_task_id(request: Request):
|
| 10 |
+
task_id = request.headers.get("x-task-id")
|
| 11 |
+
if not task_id:
|
| 12 |
+
task_id = uuid4()
|
| 13 |
+
return str(task_id)
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def get_api_key(request: Request):
|
| 17 |
+
api_key = request.headers.get("x-api-key")
|
| 18 |
+
return api_key
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def verify_token(request: Request):
|
| 22 |
+
token = get_api_key(request)
|
| 23 |
+
if token != config.app.get("api_key", ""):
|
| 24 |
+
request_id = get_task_id(request)
|
| 25 |
+
request_url = request.url
|
| 26 |
+
user_agent = request.headers.get("user-agent")
|
| 27 |
+
raise HttpException(
|
| 28 |
+
task_id=request_id,
|
| 29 |
+
status_code=401,
|
| 30 |
+
message=f"invalid token: {request_url}, {user_agent}",
|
| 31 |
+
)
|
app/controllers/manager/base_manager.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import threading
|
| 2 |
+
from typing import Any, Callable, Dict
|
| 3 |
+
|
| 4 |
+
from loguru import logger
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
class TaskQueueFullError(ValueError):
|
| 8 |
+
pass
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class TaskManager:
|
| 12 |
+
def __init__(self, max_concurrent_tasks: int, max_queued_tasks: int = 100):
|
| 13 |
+
self.max_concurrent_tasks = max_concurrent_tasks
|
| 14 |
+
self.max_queued_tasks = max_queued_tasks
|
| 15 |
+
self.current_tasks = 0
|
| 16 |
+
self.lock = threading.Lock()
|
| 17 |
+
self.queue = self.create_queue()
|
| 18 |
+
|
| 19 |
+
def create_queue(self):
|
| 20 |
+
raise NotImplementedError()
|
| 21 |
+
|
| 22 |
+
def add_task(self, func: Callable, *args: Any, **kwargs: Any):
|
| 23 |
+
with self.lock:
|
| 24 |
+
if self.current_tasks < self.max_concurrent_tasks:
|
| 25 |
+
logger.info(
|
| 26 |
+
f"add task: {func.__name__}, current_tasks: {self.current_tasks}"
|
| 27 |
+
)
|
| 28 |
+
self.execute_task(func, *args, **kwargs)
|
| 29 |
+
else:
|
| 30 |
+
queue_size = self.queue_size()
|
| 31 |
+
# 并发数已满时才进入排队。队列必须有上限,否则匿名接口可以持续
|
| 32 |
+
# 堆积任务对象和请求参数,最终造成内存耗尽或第三方 API 成本失控。
|
| 33 |
+
if queue_size >= self.max_queued_tasks:
|
| 34 |
+
logger.warning(
|
| 35 |
+
f"reject task: {func.__name__}, queue_size: {queue_size}, "
|
| 36 |
+
f"max_queued_tasks: {self.max_queued_tasks}"
|
| 37 |
+
)
|
| 38 |
+
raise TaskQueueFullError("task queue is full, please try again later")
|
| 39 |
+
|
| 40 |
+
logger.info(
|
| 41 |
+
f"enqueue task: {func.__name__}, current_tasks: {self.current_tasks}, "
|
| 42 |
+
f"queue_size: {queue_size}"
|
| 43 |
+
)
|
| 44 |
+
self.enqueue({"func": func, "args": args, "kwargs": kwargs})
|
| 45 |
+
|
| 46 |
+
def execute_task(self, func: Callable, *args: Any, **kwargs: Any):
|
| 47 |
+
thread = threading.Thread(
|
| 48 |
+
target=self.run_task, args=(func, *args), kwargs=kwargs
|
| 49 |
+
)
|
| 50 |
+
thread.start()
|
| 51 |
+
|
| 52 |
+
def run_task(self, func: Callable, *args: Any, **kwargs: Any):
|
| 53 |
+
try:
|
| 54 |
+
with self.lock:
|
| 55 |
+
self.current_tasks += 1
|
| 56 |
+
func(*args, **kwargs) # call the function here, passing *args and **kwargs.
|
| 57 |
+
finally:
|
| 58 |
+
self.task_done()
|
| 59 |
+
|
| 60 |
+
def check_queue(self):
|
| 61 |
+
with self.lock:
|
| 62 |
+
if (
|
| 63 |
+
self.current_tasks < self.max_concurrent_tasks
|
| 64 |
+
and not self.is_queue_empty()
|
| 65 |
+
):
|
| 66 |
+
task_info = self.dequeue()
|
| 67 |
+
func = task_info["func"]
|
| 68 |
+
args = task_info.get("args", ())
|
| 69 |
+
kwargs = task_info.get("kwargs", {})
|
| 70 |
+
self.execute_task(func, *args, **kwargs)
|
| 71 |
+
|
| 72 |
+
def task_done(self):
|
| 73 |
+
with self.lock:
|
| 74 |
+
self.current_tasks -= 1
|
| 75 |
+
self.check_queue()
|
| 76 |
+
|
| 77 |
+
def enqueue(self, task: Dict):
|
| 78 |
+
raise NotImplementedError()
|
| 79 |
+
|
| 80 |
+
def dequeue(self):
|
| 81 |
+
raise NotImplementedError()
|
| 82 |
+
|
| 83 |
+
def is_queue_empty(self):
|
| 84 |
+
raise NotImplementedError()
|
| 85 |
+
|
| 86 |
+
def queue_size(self):
|
| 87 |
+
raise NotImplementedError()
|
app/controllers/manager/memory_manager.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from queue import Queue
|
| 2 |
+
from typing import Dict
|
| 3 |
+
|
| 4 |
+
from app.controllers.manager.base_manager import TaskManager
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
class InMemoryTaskManager(TaskManager):
|
| 8 |
+
def create_queue(self):
|
| 9 |
+
return Queue(maxsize=self.max_queued_tasks)
|
| 10 |
+
|
| 11 |
+
def enqueue(self, task: Dict):
|
| 12 |
+
self.queue.put(task)
|
| 13 |
+
|
| 14 |
+
def dequeue(self):
|
| 15 |
+
return self.queue.get()
|
| 16 |
+
|
| 17 |
+
def is_queue_empty(self):
|
| 18 |
+
return self.queue.empty()
|
| 19 |
+
|
| 20 |
+
def queue_size(self):
|
| 21 |
+
return self.queue.qsize()
|
app/controllers/manager/redis_manager.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
from typing import Dict
|
| 3 |
+
|
| 4 |
+
import redis
|
| 5 |
+
|
| 6 |
+
from app.controllers.manager.base_manager import TaskManager
|
| 7 |
+
from app.models.schema import VideoParams
|
| 8 |
+
from app.services import task as tm
|
| 9 |
+
|
| 10 |
+
FUNC_MAP = {
|
| 11 |
+
"start": tm.start,
|
| 12 |
+
# 'start_test': tm.start_test
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class RedisTaskManager(TaskManager):
|
| 17 |
+
def __init__(
|
| 18 |
+
self,
|
| 19 |
+
max_concurrent_tasks: int,
|
| 20 |
+
redis_url: str,
|
| 21 |
+
max_queued_tasks: int = 100,
|
| 22 |
+
):
|
| 23 |
+
self.redis_client = redis.Redis.from_url(redis_url)
|
| 24 |
+
super().__init__(max_concurrent_tasks, max_queued_tasks=max_queued_tasks)
|
| 25 |
+
|
| 26 |
+
def create_queue(self):
|
| 27 |
+
return "task_queue"
|
| 28 |
+
|
| 29 |
+
def enqueue(self, task: Dict):
|
| 30 |
+
task_with_serializable_params = task.copy()
|
| 31 |
+
|
| 32 |
+
if "params" in task["kwargs"] and isinstance(
|
| 33 |
+
task["kwargs"]["params"], VideoParams
|
| 34 |
+
):
|
| 35 |
+
task_with_serializable_params["kwargs"]["params"] = task["kwargs"][
|
| 36 |
+
"params"
|
| 37 |
+
].dict()
|
| 38 |
+
|
| 39 |
+
# 将函数对象转换为其名称
|
| 40 |
+
task_with_serializable_params["func"] = task["func"].__name__
|
| 41 |
+
self.redis_client.rpush(self.queue, json.dumps(task_with_serializable_params))
|
| 42 |
+
|
| 43 |
+
def dequeue(self):
|
| 44 |
+
task_json = self.redis_client.lpop(self.queue)
|
| 45 |
+
if task_json:
|
| 46 |
+
task_info = json.loads(task_json)
|
| 47 |
+
# 将函数名称转换回函数对象
|
| 48 |
+
task_info["func"] = FUNC_MAP[task_info["func"]]
|
| 49 |
+
|
| 50 |
+
if "params" in task_info["kwargs"] and isinstance(
|
| 51 |
+
task_info["kwargs"]["params"], dict
|
| 52 |
+
):
|
| 53 |
+
task_info["kwargs"]["params"] = VideoParams(
|
| 54 |
+
**task_info["kwargs"]["params"]
|
| 55 |
+
)
|
| 56 |
+
|
| 57 |
+
return task_info
|
| 58 |
+
return None
|
| 59 |
+
|
| 60 |
+
def is_queue_empty(self):
|
| 61 |
+
return self.redis_client.llen(self.queue) == 0
|
| 62 |
+
|
| 63 |
+
def queue_size(self):
|
| 64 |
+
return self.redis_client.llen(self.queue)
|
app/controllers/ping.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Request
|
| 2 |
+
|
| 3 |
+
router = APIRouter()
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
@router.get(
|
| 7 |
+
"/ping",
|
| 8 |
+
tags=["Health Check"],
|
| 9 |
+
description="检查服务可用性",
|
| 10 |
+
response_description="pong",
|
| 11 |
+
)
|
| 12 |
+
def ping(request: Request) -> str:
|
| 13 |
+
return "pong"
|
app/controllers/v1/base.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
def new_router(dependencies=None):
|
| 5 |
+
router = APIRouter()
|
| 6 |
+
router.tags = ["V1"]
|
| 7 |
+
router.prefix = "/api/v1"
|
| 8 |
+
# 将认证依赖项应用于所有路由
|
| 9 |
+
if dependencies:
|
| 10 |
+
router.dependencies = dependencies
|
| 11 |
+
return router
|
app/controllers/v1/llm.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import Request
|
| 2 |
+
|
| 3 |
+
from app.controllers.v1.base import new_router
|
| 4 |
+
from app.models.schema import (
|
| 5 |
+
VideoScriptRequest,
|
| 6 |
+
VideoScriptResponse,
|
| 7 |
+
VideoTermsRequest,
|
| 8 |
+
VideoTermsResponse,
|
| 9 |
+
)
|
| 10 |
+
from app.services import llm
|
| 11 |
+
from app.utils import utils
|
| 12 |
+
|
| 13 |
+
# authentication dependency
|
| 14 |
+
# router = new_router(dependencies=[Depends(base.verify_token)])
|
| 15 |
+
router = new_router()
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
@router.post(
|
| 19 |
+
"/scripts",
|
| 20 |
+
response_model=VideoScriptResponse,
|
| 21 |
+
summary="Create a script for the video",
|
| 22 |
+
)
|
| 23 |
+
def generate_video_script(request: Request, body: VideoScriptRequest):
|
| 24 |
+
video_script = llm.generate_script(
|
| 25 |
+
video_subject=body.video_subject,
|
| 26 |
+
language=body.video_language,
|
| 27 |
+
paragraph_number=body.paragraph_number,
|
| 28 |
+
video_script_prompt=body.video_script_prompt,
|
| 29 |
+
custom_system_prompt=body.custom_system_prompt,
|
| 30 |
+
)
|
| 31 |
+
response = {"video_script": video_script}
|
| 32 |
+
return utils.get_response(200, response)
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
@router.post(
|
| 36 |
+
"/terms",
|
| 37 |
+
response_model=VideoTermsResponse,
|
| 38 |
+
summary="Generate video terms based on the video script",
|
| 39 |
+
)
|
| 40 |
+
def generate_video_terms(request: Request, body: VideoTermsRequest):
|
| 41 |
+
video_terms = llm.generate_terms(
|
| 42 |
+
video_subject=body.video_subject,
|
| 43 |
+
video_script=body.video_script,
|
| 44 |
+
amount=body.amount,
|
| 45 |
+
)
|
| 46 |
+
response = {"video_terms": video_terms}
|
| 47 |
+
return utils.get_response(200, response)
|
app/controllers/v1/video.py
ADDED
|
@@ -0,0 +1,400 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import glob
|
| 2 |
+
import os
|
| 3 |
+
import pathlib
|
| 4 |
+
import shutil
|
| 5 |
+
from typing import Union
|
| 6 |
+
|
| 7 |
+
from fastapi import BackgroundTasks, Depends, Path, Query, Request, UploadFile
|
| 8 |
+
from fastapi.params import File
|
| 9 |
+
from fastapi.responses import FileResponse, StreamingResponse
|
| 10 |
+
from loguru import logger
|
| 11 |
+
|
| 12 |
+
from app.config import config
|
| 13 |
+
from app.controllers import base
|
| 14 |
+
from app.controllers.manager.base_manager import TaskQueueFullError
|
| 15 |
+
from app.controllers.manager.memory_manager import InMemoryTaskManager
|
| 16 |
+
from app.controllers.manager.redis_manager import RedisTaskManager
|
| 17 |
+
from app.controllers.v1.base import new_router
|
| 18 |
+
from app.models.exception import HttpException
|
| 19 |
+
from app.models.schema import (
|
| 20 |
+
AudioRequest,
|
| 21 |
+
BgmRetrieveResponse,
|
| 22 |
+
BgmUploadResponse,
|
| 23 |
+
SubtitleRequest,
|
| 24 |
+
TaskDeletionResponse,
|
| 25 |
+
TaskQueryRequest,
|
| 26 |
+
TaskQueryResponse,
|
| 27 |
+
TaskResponse,
|
| 28 |
+
TaskVideoRequest,
|
| 29 |
+
VideoMaterialUploadResponse,
|
| 30 |
+
VideoMaterialRetrieveResponse
|
| 31 |
+
)
|
| 32 |
+
from app.services import state as sm
|
| 33 |
+
from app.services import task as tm
|
| 34 |
+
from app.utils import file_security, utils
|
| 35 |
+
|
| 36 |
+
# 认证依赖项
|
| 37 |
+
# router = new_router(dependencies=[Depends(base.verify_token)])
|
| 38 |
+
router = new_router()
|
| 39 |
+
|
| 40 |
+
_enable_redis = config.app.get("enable_redis", False)
|
| 41 |
+
_redis_host = config.app.get("redis_host", "localhost")
|
| 42 |
+
_redis_port = config.app.get("redis_port", 6379)
|
| 43 |
+
_redis_db = config.app.get("redis_db", 0)
|
| 44 |
+
_redis_password = config.app.get("redis_password", None)
|
| 45 |
+
_max_concurrent_tasks = config.app.get("max_concurrent_tasks", 5)
|
| 46 |
+
_max_queued_tasks = config.app.get("max_queued_tasks", 100)
|
| 47 |
+
|
| 48 |
+
redis_url = f"redis://:{_redis_password}@{_redis_host}:{_redis_port}/{_redis_db}"
|
| 49 |
+
# 根据配置选择合适的任务管理器
|
| 50 |
+
if _enable_redis:
|
| 51 |
+
task_manager = RedisTaskManager(
|
| 52 |
+
max_concurrent_tasks=_max_concurrent_tasks,
|
| 53 |
+
redis_url=redis_url,
|
| 54 |
+
max_queued_tasks=_max_queued_tasks,
|
| 55 |
+
)
|
| 56 |
+
else:
|
| 57 |
+
task_manager = InMemoryTaskManager(
|
| 58 |
+
max_concurrent_tasks=_max_concurrent_tasks,
|
| 59 |
+
max_queued_tasks=_max_queued_tasks,
|
| 60 |
+
)
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def _sanitize_upload_filename(filename: str, request_id: str) -> str:
|
| 64 |
+
# 浏览器或客户端有时会附带目录信息,甚至可能夹带 ../ 这类穿越片段。
|
| 65 |
+
# 这里只保留纯文件名,避免上传接口把文件写到目标目录之外。
|
| 66 |
+
normalized_name = (filename or "").replace("\\", "/").split("/")[-1].strip()
|
| 67 |
+
if not normalized_name or normalized_name in {".", ".."}:
|
| 68 |
+
raise HttpException(
|
| 69 |
+
task_id=request_id,
|
| 70 |
+
status_code=400,
|
| 71 |
+
message=f"{request_id}: invalid filename",
|
| 72 |
+
)
|
| 73 |
+
return normalized_name
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def _resolve_path_within_directory(base_dir: str, unsafe_path: str, request_id: str) -> str:
|
| 77 |
+
try:
|
| 78 |
+
return file_security.resolve_path_within_directory(base_dir, unsafe_path)
|
| 79 |
+
except ValueError as exc:
|
| 80 |
+
logger.warning(
|
| 81 |
+
f"reject unsafe file path, request_id: {request_id}, path: {unsafe_path}, "
|
| 82 |
+
f"error: {str(exc)}"
|
| 83 |
+
)
|
| 84 |
+
raise HttpException(
|
| 85 |
+
task_id=request_id,
|
| 86 |
+
status_code=404 if str(exc) == "file does not exist" else 403,
|
| 87 |
+
message=f"{request_id}: invalid file path",
|
| 88 |
+
)
|
| 89 |
+
|
| 90 |
+
def _task_file_to_uri(file: str, endpoint: str, task_dir: str, request_id: str) -> str:
|
| 91 |
+
if not isinstance(file, str):
|
| 92 |
+
return file
|
| 93 |
+
|
| 94 |
+
if file.startswith(("http://", "https://")):
|
| 95 |
+
return file
|
| 96 |
+
|
| 97 |
+
try:
|
| 98 |
+
resolved_path = file_security.resolve_path_within_directory(task_dir, file)
|
| 99 |
+
except ValueError as exc:
|
| 100 |
+
# 任务状态理论上只应保存任务目录内的产物路径。这里不再继续拼接 URL,
|
| 101 |
+
# 避免把异常路径包装成可访问链接;同时保留原值,便于排查历史脏数据。
|
| 102 |
+
logger.warning(
|
| 103 |
+
f"skip unsafe task output path, request_id: {request_id}, path: {file}, "
|
| 104 |
+
f"error: {str(exc)}"
|
| 105 |
+
)
|
| 106 |
+
return file
|
| 107 |
+
|
| 108 |
+
relative_path = os.path.relpath(resolved_path, task_dir).replace("\\", "/")
|
| 109 |
+
uri_path = f"tasks/{relative_path}"
|
| 110 |
+
if endpoint:
|
| 111 |
+
return f"{endpoint.rstrip('/')}/{uri_path}"
|
| 112 |
+
return f"/{uri_path}"
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
@router.post("/videos", response_model=TaskResponse, summary="Generate a short video")
|
| 116 |
+
def create_video(
|
| 117 |
+
background_tasks: BackgroundTasks, request: Request, body: TaskVideoRequest
|
| 118 |
+
):
|
| 119 |
+
return create_task(request, body, stop_at="video")
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
@router.post("/subtitle", response_model=TaskResponse, summary="Generate subtitle only")
|
| 123 |
+
def create_subtitle(
|
| 124 |
+
background_tasks: BackgroundTasks, request: Request, body: SubtitleRequest
|
| 125 |
+
):
|
| 126 |
+
return create_task(request, body, stop_at="subtitle")
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
@router.post("/audio", response_model=TaskResponse, summary="Generate audio only")
|
| 130 |
+
def create_audio(
|
| 131 |
+
background_tasks: BackgroundTasks, request: Request, body: AudioRequest
|
| 132 |
+
):
|
| 133 |
+
return create_task(request, body, stop_at="audio")
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
def create_task(
|
| 137 |
+
request: Request,
|
| 138 |
+
body: Union[TaskVideoRequest, SubtitleRequest, AudioRequest],
|
| 139 |
+
stop_at: str,
|
| 140 |
+
):
|
| 141 |
+
task_id = utils.get_uuid()
|
| 142 |
+
request_id = base.get_task_id(request)
|
| 143 |
+
try:
|
| 144 |
+
task = {
|
| 145 |
+
"task_id": task_id,
|
| 146 |
+
"request_id": request_id,
|
| 147 |
+
"params": body.model_dump(),
|
| 148 |
+
}
|
| 149 |
+
sm.state.update_task(task_id)
|
| 150 |
+
task_manager.add_task(tm.start, task_id=task_id, params=body, stop_at=stop_at)
|
| 151 |
+
logger.success(f"Task created: {utils.to_json(task)}")
|
| 152 |
+
return utils.get_response(200, task)
|
| 153 |
+
except TaskQueueFullError as e:
|
| 154 |
+
sm.state.delete_task(task_id)
|
| 155 |
+
logger.warning(
|
| 156 |
+
f"reject task because queue is full, request_id: {request_id}, task_id: {task_id}"
|
| 157 |
+
)
|
| 158 |
+
raise HttpException(
|
| 159 |
+
task_id=task_id, status_code=429, message=f"{request_id}: {str(e)}"
|
| 160 |
+
)
|
| 161 |
+
except ValueError as e:
|
| 162 |
+
raise HttpException(
|
| 163 |
+
task_id=task_id, status_code=400, message=f"{request_id}: {str(e)}"
|
| 164 |
+
)
|
| 165 |
+
|
| 166 |
+
@router.get("/tasks", response_model=TaskQueryResponse, summary="Get all tasks")
|
| 167 |
+
def get_all_tasks(request: Request, page: int = Query(1, ge=1), page_size: int = Query(10, ge=1)):
|
| 168 |
+
tasks, total = sm.state.get_all_tasks(page, page_size)
|
| 169 |
+
|
| 170 |
+
response = {
|
| 171 |
+
"tasks": tasks,
|
| 172 |
+
"total": total,
|
| 173 |
+
"page": page,
|
| 174 |
+
"page_size": page_size,
|
| 175 |
+
}
|
| 176 |
+
return utils.get_response(200, response)
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
@router.get(
|
| 181 |
+
"/tasks/{task_id}", response_model=TaskQueryResponse, summary="Query task status"
|
| 182 |
+
)
|
| 183 |
+
def get_task(
|
| 184 |
+
request: Request,
|
| 185 |
+
task_id: str = Path(..., description="Task ID"),
|
| 186 |
+
query: TaskQueryRequest = Depends(),
|
| 187 |
+
):
|
| 188 |
+
request_id = base.get_task_id(request)
|
| 189 |
+
endpoint = config.app.get("endpoint", "").rstrip("/")
|
| 190 |
+
task = sm.state.get_task(task_id)
|
| 191 |
+
if task:
|
| 192 |
+
task_dir = utils.task_dir()
|
| 193 |
+
response_task = dict(task)
|
| 194 |
+
|
| 195 |
+
if "videos" in task:
|
| 196 |
+
response_task["videos"] = [
|
| 197 |
+
_task_file_to_uri(v, endpoint, task_dir, request_id)
|
| 198 |
+
for v in task["videos"]
|
| 199 |
+
]
|
| 200 |
+
if "combined_videos" in task:
|
| 201 |
+
response_task["combined_videos"] = [
|
| 202 |
+
_task_file_to_uri(v, endpoint, task_dir, request_id)
|
| 203 |
+
for v in task["combined_videos"]
|
| 204 |
+
]
|
| 205 |
+
return utils.get_response(200, response_task)
|
| 206 |
+
|
| 207 |
+
raise HttpException(
|
| 208 |
+
task_id=task_id, status_code=404, message=f"{request_id}: task not found"
|
| 209 |
+
)
|
| 210 |
+
|
| 211 |
+
|
| 212 |
+
@router.delete(
|
| 213 |
+
"/tasks/{task_id}",
|
| 214 |
+
response_model=TaskDeletionResponse,
|
| 215 |
+
summary="Delete a generated short video task",
|
| 216 |
+
)
|
| 217 |
+
def delete_video(request: Request, task_id: str = Path(..., description="Task ID")):
|
| 218 |
+
request_id = base.get_task_id(request)
|
| 219 |
+
task = sm.state.get_task(task_id)
|
| 220 |
+
if task:
|
| 221 |
+
tasks_dir = utils.task_dir()
|
| 222 |
+
current_task_dir = os.path.join(tasks_dir, task_id)
|
| 223 |
+
if os.path.exists(current_task_dir):
|
| 224 |
+
shutil.rmtree(current_task_dir)
|
| 225 |
+
|
| 226 |
+
sm.state.delete_task(task_id)
|
| 227 |
+
logger.success(f"video deleted: {utils.to_json(task)}")
|
| 228 |
+
return utils.get_response(200)
|
| 229 |
+
|
| 230 |
+
raise HttpException(
|
| 231 |
+
task_id=task_id, status_code=404, message=f"{request_id}: task not found"
|
| 232 |
+
)
|
| 233 |
+
|
| 234 |
+
|
| 235 |
+
@router.get(
|
| 236 |
+
"/musics", response_model=BgmRetrieveResponse, summary="Retrieve local BGM files"
|
| 237 |
+
)
|
| 238 |
+
def get_bgm_list(request: Request):
|
| 239 |
+
suffix = "*.mp3"
|
| 240 |
+
song_dir = utils.song_dir()
|
| 241 |
+
files = glob.glob(os.path.join(song_dir, suffix))
|
| 242 |
+
bgm_list = []
|
| 243 |
+
for file in files:
|
| 244 |
+
filename = os.path.basename(file)
|
| 245 |
+
bgm_list.append(
|
| 246 |
+
{
|
| 247 |
+
"name": filename,
|
| 248 |
+
"size": os.path.getsize(file),
|
| 249 |
+
# 只返回文件名,避免把服务器绝对路径暴露给调用方。
|
| 250 |
+
# 服务端后续会把该文件名解析回 songs 白名单目录。
|
| 251 |
+
"file": filename,
|
| 252 |
+
}
|
| 253 |
+
)
|
| 254 |
+
response = {"files": bgm_list}
|
| 255 |
+
return utils.get_response(200, response)
|
| 256 |
+
|
| 257 |
+
|
| 258 |
+
@router.post(
|
| 259 |
+
"/musics",
|
| 260 |
+
response_model=BgmUploadResponse,
|
| 261 |
+
summary="Upload the BGM file to the songs directory",
|
| 262 |
+
)
|
| 263 |
+
def upload_bgm_file(request: Request, file: UploadFile = File(...)):
|
| 264 |
+
request_id = base.get_task_id(request)
|
| 265 |
+
safe_filename = _sanitize_upload_filename(file.filename, request_id)
|
| 266 |
+
# check file ext
|
| 267 |
+
if safe_filename.lower().endswith("mp3"):
|
| 268 |
+
song_dir = utils.song_dir()
|
| 269 |
+
save_path = os.path.join(song_dir, safe_filename)
|
| 270 |
+
# save file
|
| 271 |
+
with open(save_path, "wb+") as buffer:
|
| 272 |
+
# If the file already exists, it will be overwritten
|
| 273 |
+
file.file.seek(0)
|
| 274 |
+
buffer.write(file.file.read())
|
| 275 |
+
response = {"file": safe_filename}
|
| 276 |
+
return utils.get_response(200, response)
|
| 277 |
+
|
| 278 |
+
raise HttpException(
|
| 279 |
+
"", status_code=400, message=f"{request_id}: Only *.mp3 files can be uploaded"
|
| 280 |
+
)
|
| 281 |
+
|
| 282 |
+
@router.get(
|
| 283 |
+
"/video_materials", response_model=VideoMaterialRetrieveResponse, summary="Retrieve local video materials"
|
| 284 |
+
)
|
| 285 |
+
def get_video_materials_list(request: Request):
|
| 286 |
+
allowed_suffixes = ("mp4", "mov", "avi", "flv", "mkv", "jpg", "jpeg", "png")
|
| 287 |
+
local_videos_dir = utils.storage_dir("local_videos", create=True)
|
| 288 |
+
files = []
|
| 289 |
+
for suffix in allowed_suffixes:
|
| 290 |
+
files.extend(glob.glob(os.path.join(local_videos_dir, f"*.{suffix}")))
|
| 291 |
+
# 文件系统枚举顺序不稳定,直接返回会导致“顺序拼接”在不同机器或不同
|
| 292 |
+
# 时刻表现不一致。这里统一按文件名排序,至少保证服务端返回顺序可预测。
|
| 293 |
+
files.sort(key=lambda file_path: os.path.basename(file_path).lower())
|
| 294 |
+
video_materials_list = []
|
| 295 |
+
for file in files:
|
| 296 |
+
filename = os.path.basename(file)
|
| 297 |
+
video_materials_list.append(
|
| 298 |
+
{
|
| 299 |
+
"name": filename,
|
| 300 |
+
"size": os.path.getsize(file),
|
| 301 |
+
# 与 BGM 一样,只返回文件名;创建任务时再在 local_videos
|
| 302 |
+
# 白名单目录内解析,避免 API 泄露宿主机绝对路径。
|
| 303 |
+
"file": filename,
|
| 304 |
+
}
|
| 305 |
+
)
|
| 306 |
+
response = {"files": video_materials_list}
|
| 307 |
+
return utils.get_response(200, response)
|
| 308 |
+
|
| 309 |
+
|
| 310 |
+
@router.post(
|
| 311 |
+
"/video_materials",
|
| 312 |
+
response_model=VideoMaterialUploadResponse,
|
| 313 |
+
summary="Upload the video material file to the local videos directory",
|
| 314 |
+
)
|
| 315 |
+
def upload_video_material_file(request: Request, file: UploadFile = File(...)):
|
| 316 |
+
request_id = base.get_task_id(request)
|
| 317 |
+
safe_filename = _sanitize_upload_filename(file.filename, request_id)
|
| 318 |
+
# check file ext
|
| 319 |
+
allowed_suffixes = ("mp4", "mov", "avi", "flv", "mkv", "jpg", "jpeg", "png")
|
| 320 |
+
normalized_filename = safe_filename.lower()
|
| 321 |
+
# 统一按小写扩展名校验,兼容 .MOV 这类大写后缀文件。
|
| 322 |
+
if normalized_filename.endswith(allowed_suffixes):
|
| 323 |
+
local_videos_dir = utils.storage_dir("local_videos", create=True)
|
| 324 |
+
save_path = os.path.join(local_videos_dir, safe_filename)
|
| 325 |
+
# save file
|
| 326 |
+
with open(save_path, "wb+") as buffer:
|
| 327 |
+
# If the file already exists, it will be overwritten
|
| 328 |
+
file.file.seek(0)
|
| 329 |
+
buffer.write(file.file.read())
|
| 330 |
+
response = {"file": safe_filename}
|
| 331 |
+
return utils.get_response(200, response)
|
| 332 |
+
|
| 333 |
+
raise HttpException(
|
| 334 |
+
"", status_code=400, message=f"{request_id}: Only files with extensions {', '.join(allowed_suffixes)} can be uploaded"
|
| 335 |
+
)
|
| 336 |
+
|
| 337 |
+
@router.get("/stream/{file_path:path}")
|
| 338 |
+
async def stream_video(request: Request, file_path: str):
|
| 339 |
+
request_id = base.get_task_id(request)
|
| 340 |
+
tasks_dir = utils.task_dir()
|
| 341 |
+
video_path = _resolve_path_within_directory(tasks_dir, file_path, request_id)
|
| 342 |
+
range_header = request.headers.get("Range")
|
| 343 |
+
video_size = os.path.getsize(video_path)
|
| 344 |
+
start, end = 0, video_size - 1
|
| 345 |
+
|
| 346 |
+
length = video_size
|
| 347 |
+
if range_header:
|
| 348 |
+
range_ = range_header.split("bytes=")[1]
|
| 349 |
+
start, end = [int(part) if part else None for part in range_.split("-")]
|
| 350 |
+
if start is None:
|
| 351 |
+
start = video_size - end
|
| 352 |
+
end = video_size - 1
|
| 353 |
+
if end is None:
|
| 354 |
+
end = video_size - 1
|
| 355 |
+
length = end - start + 1
|
| 356 |
+
|
| 357 |
+
def file_iterator(file_path, offset=0, bytes_to_read=None):
|
| 358 |
+
with open(file_path, "rb") as f:
|
| 359 |
+
f.seek(offset, os.SEEK_SET)
|
| 360 |
+
remaining = bytes_to_read or video_size
|
| 361 |
+
while remaining > 0:
|
| 362 |
+
bytes_to_read = min(4096, remaining)
|
| 363 |
+
data = f.read(bytes_to_read)
|
| 364 |
+
if not data:
|
| 365 |
+
break
|
| 366 |
+
remaining -= len(data)
|
| 367 |
+
yield data
|
| 368 |
+
|
| 369 |
+
response = StreamingResponse(
|
| 370 |
+
file_iterator(video_path, start, length), media_type="video/mp4"
|
| 371 |
+
)
|
| 372 |
+
response.headers["Content-Range"] = f"bytes {start}-{end}/{video_size}"
|
| 373 |
+
response.headers["Accept-Ranges"] = "bytes"
|
| 374 |
+
response.headers["Content-Length"] = str(length)
|
| 375 |
+
response.status_code = 206 # Partial Content
|
| 376 |
+
|
| 377 |
+
return response
|
| 378 |
+
|
| 379 |
+
|
| 380 |
+
@router.get("/download/{file_path:path}")
|
| 381 |
+
async def download_video(request: Request, file_path: str):
|
| 382 |
+
"""
|
| 383 |
+
download video
|
| 384 |
+
:param request: Request request
|
| 385 |
+
:param file_path: video file path, eg: /cd1727ed-3473-42a2-a7da-4faafafec72b/final-1.mp4
|
| 386 |
+
:return: video file
|
| 387 |
+
"""
|
| 388 |
+
request_id = base.get_task_id(request)
|
| 389 |
+
tasks_dir = utils.task_dir()
|
| 390 |
+
video_path = _resolve_path_within_directory(tasks_dir, file_path, request_id)
|
| 391 |
+
file_path = pathlib.Path(video_path)
|
| 392 |
+
filename = file_path.stem
|
| 393 |
+
extension = file_path.suffix
|
| 394 |
+
headers = {"Content-Disposition": f"attachment; filename={filename}{extension}"}
|
| 395 |
+
return FileResponse(
|
| 396 |
+
path=video_path,
|
| 397 |
+
headers=headers,
|
| 398 |
+
filename=f"{filename}{extension}",
|
| 399 |
+
media_type=f"video/{extension[1:]}",
|
| 400 |
+
)
|
app/models/__init__.py
ADDED
|
File without changes
|
app/models/const.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
PUNCTUATIONS = [
|
| 2 |
+
"?",
|
| 3 |
+
",",
|
| 4 |
+
".",
|
| 5 |
+
"、",
|
| 6 |
+
";",
|
| 7 |
+
":",
|
| 8 |
+
"!",
|
| 9 |
+
"…",
|
| 10 |
+
"?",
|
| 11 |
+
",",
|
| 12 |
+
"。",
|
| 13 |
+
"、",
|
| 14 |
+
";",
|
| 15 |
+
":",
|
| 16 |
+
"!",
|
| 17 |
+
"...",
|
| 18 |
+
# 阿拉伯语常用标点也应作为自然断句点,避免脚本文本和 edge-tts
|
| 19 |
+
# 返回的字幕停顿边界不一致,导致后续逐行匹配失败。
|
| 20 |
+
"،",
|
| 21 |
+
"؛",
|
| 22 |
+
"؟",
|
| 23 |
+
]
|
| 24 |
+
|
| 25 |
+
TASK_STATE_FAILED = -1
|
| 26 |
+
TASK_STATE_COMPLETE = 1
|
| 27 |
+
TASK_STATE_PROCESSING = 4
|
| 28 |
+
|
| 29 |
+
FILE_TYPE_VIDEOS = ["mp4", "mov", "mkv", "webm"]
|
| 30 |
+
FILE_TYPE_IMAGES = ["jpg", "jpeg", "png", "bmp"]
|
app/models/exception.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import traceback
|
| 2 |
+
from typing import Any
|
| 3 |
+
|
| 4 |
+
from loguru import logger
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
class HttpException(Exception):
|
| 8 |
+
def __init__(
|
| 9 |
+
self, task_id: str, status_code: int, message: str = "", data: Any = None
|
| 10 |
+
):
|
| 11 |
+
self.message = message
|
| 12 |
+
self.status_code = status_code
|
| 13 |
+
self.data = data
|
| 14 |
+
# Retrieve the exception stack trace information.
|
| 15 |
+
tb_str = traceback.format_exc().strip()
|
| 16 |
+
if not tb_str or tb_str == "NoneType: None":
|
| 17 |
+
msg = f"HttpException: {status_code}, {task_id}, {message}"
|
| 18 |
+
else:
|
| 19 |
+
msg = f"HttpException: {status_code}, {task_id}, {message}\n{tb_str}"
|
| 20 |
+
|
| 21 |
+
if status_code == 400:
|
| 22 |
+
logger.warning(msg)
|
| 23 |
+
else:
|
| 24 |
+
logger.error(msg)
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
class FileNotFoundException(Exception):
|
| 28 |
+
pass
|
app/models/schema.py
ADDED
|
@@ -0,0 +1,344 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import warnings
|
| 2 |
+
from enum import Enum
|
| 3 |
+
from typing import Any, List, Optional, Union
|
| 4 |
+
|
| 5 |
+
import pydantic
|
| 6 |
+
from pydantic import BaseModel, Field
|
| 7 |
+
|
| 8 |
+
from app.config import config
|
| 9 |
+
|
| 10 |
+
# 忽略 Pydantic 的特定警告
|
| 11 |
+
warnings.filterwarnings(
|
| 12 |
+
"ignore",
|
| 13 |
+
category=UserWarning,
|
| 14 |
+
message="Field name.*shadows an attribute in parent.*",
|
| 15 |
+
)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
class VideoConcatMode(str, Enum):
|
| 19 |
+
random = "random"
|
| 20 |
+
sequential = "sequential"
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
class VideoTransitionMode(str, Enum):
|
| 24 |
+
none = None
|
| 25 |
+
shuffle = "Shuffle"
|
| 26 |
+
fade_in = "FadeIn"
|
| 27 |
+
fade_out = "FadeOut"
|
| 28 |
+
slide_in = "SlideIn"
|
| 29 |
+
slide_out = "SlideOut"
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
class VideoAspect(str, Enum):
|
| 33 |
+
landscape = "16:9"
|
| 34 |
+
portrait = "9:16"
|
| 35 |
+
square = "1:1"
|
| 36 |
+
|
| 37 |
+
def to_resolution(self):
|
| 38 |
+
if self == VideoAspect.landscape.value:
|
| 39 |
+
return 1920, 1080
|
| 40 |
+
elif self == VideoAspect.portrait.value:
|
| 41 |
+
return 1080, 1920
|
| 42 |
+
elif self == VideoAspect.square.value:
|
| 43 |
+
return 1080, 1080
|
| 44 |
+
return 1080, 1920
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
class _Config:
|
| 48 |
+
arbitrary_types_allowed = True
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
@pydantic.dataclasses.dataclass(config=_Config)
|
| 52 |
+
class MaterialInfo:
|
| 53 |
+
provider: str = "pexels"
|
| 54 |
+
url: str = ""
|
| 55 |
+
duration: int = 0
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
class VideoParams(BaseModel):
|
| 59 |
+
"""
|
| 60 |
+
{
|
| 61 |
+
"video_subject": "",
|
| 62 |
+
"video_aspect": "横屏 16:9(西瓜视频)",
|
| 63 |
+
"voice_name": "女生-晓晓",
|
| 64 |
+
"bgm_name": "random",
|
| 65 |
+
"font_name": "STHeitiMedium 黑体-中",
|
| 66 |
+
"text_color": "#FFFFFF",
|
| 67 |
+
"font_size": 60,
|
| 68 |
+
"stroke_color": "#000000",
|
| 69 |
+
"stroke_width": 1.5
|
| 70 |
+
}
|
| 71 |
+
"""
|
| 72 |
+
|
| 73 |
+
video_subject: str
|
| 74 |
+
video_script: str = "" # Script used to generate the video
|
| 75 |
+
video_terms: Optional[str | list] = None # Keywords used to generate the video
|
| 76 |
+
video_aspect: Optional[VideoAspect] = VideoAspect.portrait.value
|
| 77 |
+
video_concat_mode: Optional[VideoConcatMode] = VideoConcatMode.random.value
|
| 78 |
+
video_transition_mode: Optional[VideoTransitionMode] = None
|
| 79 |
+
video_clip_duration: Optional[int] = 5
|
| 80 |
+
video_count: Optional[int] = 1
|
| 81 |
+
|
| 82 |
+
video_source: Optional[str] = "pexels"
|
| 83 |
+
video_materials: Optional[List[MaterialInfo]] = (
|
| 84 |
+
None # Materials used to generate the video
|
| 85 |
+
)
|
| 86 |
+
|
| 87 |
+
custom_audio_file: Optional[str] = None # Custom audio file path, will ignore video_script and disable subtitle
|
| 88 |
+
video_language: Optional[str] = "" # auto detect
|
| 89 |
+
|
| 90 |
+
voice_name: Optional[str] = ""
|
| 91 |
+
voice_volume: Optional[float] = 1.0
|
| 92 |
+
voice_rate: Optional[float] = 1.0
|
| 93 |
+
bgm_type: Optional[str] = "random"
|
| 94 |
+
bgm_file: Optional[str] = ""
|
| 95 |
+
bgm_volume: Optional[float] = 0.2
|
| 96 |
+
|
| 97 |
+
subtitle_enabled: Optional[bool] = True
|
| 98 |
+
subtitle_position: Optional[str] = config.ui.get("subtitle_position", "bottom") # top, bottom, center, custom
|
| 99 |
+
custom_position: float = config.ui.get("custom_position", 70.0)
|
| 100 |
+
font_name: Optional[str] = "STHeitiMedium.ttc"
|
| 101 |
+
text_fore_color: Optional[str] = "#FFFFFF"
|
| 102 |
+
text_background_color: Union[bool, str] = True
|
| 103 |
+
rounded_subtitle_background: bool = False
|
| 104 |
+
|
| 105 |
+
font_size: int = 60
|
| 106 |
+
stroke_color: Optional[str] = "#000000"
|
| 107 |
+
stroke_width: float = 1.5
|
| 108 |
+
n_threads: Optional[int] = 2
|
| 109 |
+
paragraph_number: int = Field(default=1, ge=1, le=10)
|
| 110 |
+
video_script_prompt: str = Field(default="", max_length=2000)
|
| 111 |
+
custom_system_prompt: str = Field(default="", max_length=8000)
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
class SubtitleRequest(BaseModel):
|
| 115 |
+
video_script: str
|
| 116 |
+
video_language: Optional[str] = ""
|
| 117 |
+
voice_name: Optional[str] = "zh-CN-XiaoxiaoNeural-Female"
|
| 118 |
+
voice_volume: Optional[float] = 1.0
|
| 119 |
+
voice_rate: Optional[float] = 1.2
|
| 120 |
+
bgm_type: Optional[str] = "random"
|
| 121 |
+
bgm_file: Optional[str] = ""
|
| 122 |
+
bgm_volume: Optional[float] = 0.2
|
| 123 |
+
subtitle_position: Optional[str] = config.ui.get("subtitle_position", "bottom")
|
| 124 |
+
font_name: Optional[str] = "STHeitiMedium.ttc"
|
| 125 |
+
text_fore_color: Optional[str] = "#FFFFFF"
|
| 126 |
+
text_background_color: Union[bool, str] = True
|
| 127 |
+
rounded_subtitle_background: bool = False
|
| 128 |
+
font_size: int = 60
|
| 129 |
+
stroke_color: Optional[str] = "#000000"
|
| 130 |
+
stroke_width: float = 1.5
|
| 131 |
+
video_source: Optional[str] = "local"
|
| 132 |
+
subtitle_enabled: Optional[str] = "true"
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
class AudioRequest(BaseModel):
|
| 136 |
+
video_script: str
|
| 137 |
+
video_language: Optional[str] = ""
|
| 138 |
+
voice_name: Optional[str] = "zh-CN-XiaoxiaoNeural-Female"
|
| 139 |
+
voice_volume: Optional[float] = 1.0
|
| 140 |
+
voice_rate: Optional[float] = 1.2
|
| 141 |
+
bgm_type: Optional[str] = "random"
|
| 142 |
+
bgm_file: Optional[str] = ""
|
| 143 |
+
bgm_volume: Optional[float] = 0.2
|
| 144 |
+
video_source: Optional[str] = "local"
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
class VideoScriptParams:
|
| 148 |
+
"""
|
| 149 |
+
{
|
| 150 |
+
"video_subject": "春天的花海",
|
| 151 |
+
"video_language": "",
|
| 152 |
+
"paragraph_number": 1,
|
| 153 |
+
"video_script_prompt": "",
|
| 154 |
+
"custom_system_prompt": ""
|
| 155 |
+
}
|
| 156 |
+
"""
|
| 157 |
+
|
| 158 |
+
video_subject: Optional[str] = "春天的花海"
|
| 159 |
+
video_language: Optional[str] = ""
|
| 160 |
+
paragraph_number: int = Field(default=1, ge=1, le=10)
|
| 161 |
+
video_script_prompt: str = Field(default="", max_length=2000)
|
| 162 |
+
custom_system_prompt: str = Field(default="", max_length=8000)
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
class VideoTermsParams:
|
| 166 |
+
"""
|
| 167 |
+
{
|
| 168 |
+
"video_subject": "",
|
| 169 |
+
"video_script": "",
|
| 170 |
+
"amount": 5
|
| 171 |
+
}
|
| 172 |
+
"""
|
| 173 |
+
|
| 174 |
+
video_subject: Optional[str] = "春天的花海"
|
| 175 |
+
video_script: Optional[str] = (
|
| 176 |
+
"春天的花海,如诗如画般展现在眼前。万物复苏的季节里,大地披上了一袭绚丽多彩的盛装。金黄的迎春、粉嫩的樱花、洁白的梨花、艳丽的郁金香……"
|
| 177 |
+
)
|
| 178 |
+
amount: Optional[int] = 5
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
class BaseResponse(BaseModel):
|
| 182 |
+
status: int = 200
|
| 183 |
+
message: Optional[str] = "success"
|
| 184 |
+
data: Any = None
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
class TaskVideoRequest(VideoParams, BaseModel):
|
| 188 |
+
pass
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
class TaskQueryRequest(BaseModel):
|
| 192 |
+
pass
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
class VideoScriptRequest(VideoScriptParams, BaseModel):
|
| 196 |
+
pass
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
class VideoTermsRequest(VideoTermsParams, BaseModel):
|
| 200 |
+
pass
|
| 201 |
+
|
| 202 |
+
|
| 203 |
+
######################################################################################################
|
| 204 |
+
######################################################################################################
|
| 205 |
+
######################################################################################################
|
| 206 |
+
######################################################################################################
|
| 207 |
+
class TaskResponse(BaseResponse):
|
| 208 |
+
class TaskResponseData(BaseModel):
|
| 209 |
+
task_id: str
|
| 210 |
+
|
| 211 |
+
data: TaskResponseData
|
| 212 |
+
|
| 213 |
+
class Config:
|
| 214 |
+
json_schema_extra = {
|
| 215 |
+
"example": {
|
| 216 |
+
"status": 200,
|
| 217 |
+
"message": "success",
|
| 218 |
+
"data": {"task_id": "6c85c8cc-a77a-42b9-bc30-947815aa0558"},
|
| 219 |
+
},
|
| 220 |
+
}
|
| 221 |
+
|
| 222 |
+
|
| 223 |
+
class TaskQueryResponse(BaseResponse):
|
| 224 |
+
class Config:
|
| 225 |
+
json_schema_extra = {
|
| 226 |
+
"example": {
|
| 227 |
+
"status": 200,
|
| 228 |
+
"message": "success",
|
| 229 |
+
"data": {
|
| 230 |
+
"state": 1,
|
| 231 |
+
"progress": 100,
|
| 232 |
+
"videos": [
|
| 233 |
+
"http://127.0.0.1:8080/tasks/6c85c8cc-a77a-42b9-bc30-947815aa0558/final-1.mp4"
|
| 234 |
+
],
|
| 235 |
+
"combined_videos": [
|
| 236 |
+
"http://127.0.0.1:8080/tasks/6c85c8cc-a77a-42b9-bc30-947815aa0558/combined-1.mp4"
|
| 237 |
+
],
|
| 238 |
+
},
|
| 239 |
+
},
|
| 240 |
+
}
|
| 241 |
+
|
| 242 |
+
|
| 243 |
+
class TaskDeletionResponse(BaseResponse):
|
| 244 |
+
class Config:
|
| 245 |
+
json_schema_extra = {
|
| 246 |
+
"example": {
|
| 247 |
+
"status": 200,
|
| 248 |
+
"message": "success",
|
| 249 |
+
"data": {
|
| 250 |
+
"state": 1,
|
| 251 |
+
"progress": 100,
|
| 252 |
+
"videos": [
|
| 253 |
+
"http://127.0.0.1:8080/tasks/6c85c8cc-a77a-42b9-bc30-947815aa0558/final-1.mp4"
|
| 254 |
+
],
|
| 255 |
+
"combined_videos": [
|
| 256 |
+
"http://127.0.0.1:8080/tasks/6c85c8cc-a77a-42b9-bc30-947815aa0558/combined-1.mp4"
|
| 257 |
+
],
|
| 258 |
+
},
|
| 259 |
+
},
|
| 260 |
+
}
|
| 261 |
+
|
| 262 |
+
|
| 263 |
+
class VideoScriptResponse(BaseResponse):
|
| 264 |
+
class Config:
|
| 265 |
+
json_schema_extra = {
|
| 266 |
+
"example": {
|
| 267 |
+
"status": 200,
|
| 268 |
+
"message": "success",
|
| 269 |
+
"data": {
|
| 270 |
+
"video_script": "春天的花海,是大自然的一幅美丽画卷。在这个季节里,大地复苏,万物生长,花朵争相绽放,形成了一片五彩斑斓的花海..."
|
| 271 |
+
},
|
| 272 |
+
},
|
| 273 |
+
}
|
| 274 |
+
|
| 275 |
+
|
| 276 |
+
class VideoTermsResponse(BaseResponse):
|
| 277 |
+
class Config:
|
| 278 |
+
json_schema_extra = {
|
| 279 |
+
"example": {
|
| 280 |
+
"status": 200,
|
| 281 |
+
"message": "success",
|
| 282 |
+
"data": {"video_terms": ["sky", "tree"]},
|
| 283 |
+
},
|
| 284 |
+
}
|
| 285 |
+
|
| 286 |
+
|
| 287 |
+
class BgmRetrieveResponse(BaseResponse):
|
| 288 |
+
class Config:
|
| 289 |
+
json_schema_extra = {
|
| 290 |
+
"example": {
|
| 291 |
+
"status": 200,
|
| 292 |
+
"message": "success",
|
| 293 |
+
"data": {
|
| 294 |
+
"files": [
|
| 295 |
+
{
|
| 296 |
+
"name": "output013.mp3",
|
| 297 |
+
"size": 1891269,
|
| 298 |
+
"file": "/MoneyPrinterTurbo/resource/songs/output013.mp3",
|
| 299 |
+
}
|
| 300 |
+
]
|
| 301 |
+
},
|
| 302 |
+
},
|
| 303 |
+
}
|
| 304 |
+
|
| 305 |
+
|
| 306 |
+
class BgmUploadResponse(BaseResponse):
|
| 307 |
+
class Config:
|
| 308 |
+
json_schema_extra = {
|
| 309 |
+
"example": {
|
| 310 |
+
"status": 200,
|
| 311 |
+
"message": "success",
|
| 312 |
+
"data": {"file": "/MoneyPrinterTurbo/resource/songs/example.mp3"},
|
| 313 |
+
},
|
| 314 |
+
}
|
| 315 |
+
|
| 316 |
+
class VideoMaterialRetrieveResponse(BaseResponse):
|
| 317 |
+
class Config:
|
| 318 |
+
json_schema_extra = {
|
| 319 |
+
"example": {
|
| 320 |
+
"status": 200,
|
| 321 |
+
"message": "success",
|
| 322 |
+
"data": {
|
| 323 |
+
"files": [
|
| 324 |
+
{
|
| 325 |
+
"name": "example.mp4",
|
| 326 |
+
"size": 12345678,
|
| 327 |
+
"file": "/MoneyPrinterTurbo/resource/videos/example.mp4",
|
| 328 |
+
}
|
| 329 |
+
]
|
| 330 |
+
},
|
| 331 |
+
},
|
| 332 |
+
}
|
| 333 |
+
|
| 334 |
+
class VideoMaterialUploadResponse(BaseResponse):
|
| 335 |
+
class Config:
|
| 336 |
+
json_schema_extra = {
|
| 337 |
+
"example": {
|
| 338 |
+
"status": 200,
|
| 339 |
+
"message": "success",
|
| 340 |
+
"data": {
|
| 341 |
+
"file": "/MoneyPrinterTurbo/resource/videos/example.mp4",
|
| 342 |
+
},
|
| 343 |
+
},
|
| 344 |
+
}
|
app/router.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Application configuration - root APIRouter.
|
| 2 |
+
|
| 3 |
+
Defines all FastAPI application endpoints.
|
| 4 |
+
|
| 5 |
+
Resources:
|
| 6 |
+
1. https://fastapi.tiangolo.com/tutorial/bigger-applications
|
| 7 |
+
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from fastapi import APIRouter
|
| 11 |
+
|
| 12 |
+
from app.controllers.v1 import llm, video
|
| 13 |
+
|
| 14 |
+
root_api_router = APIRouter()
|
| 15 |
+
# v1
|
| 16 |
+
root_api_router.include_router(video.router)
|
| 17 |
+
root_api_router.include_router(llm.router)
|
app/services/__init__.py
ADDED
|
File without changes
|
app/services/data/azure_voices.json
ADDED
|
@@ -0,0 +1,1326 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[
|
| 2 |
+
{
|
| 3 |
+
"name": "af-ZA-AdriNeural",
|
| 4 |
+
"gender": "Female"
|
| 5 |
+
},
|
| 6 |
+
{
|
| 7 |
+
"name": "af-ZA-WillemNeural",
|
| 8 |
+
"gender": "Male"
|
| 9 |
+
},
|
| 10 |
+
{
|
| 11 |
+
"name": "am-ET-AmehaNeural",
|
| 12 |
+
"gender": "Male"
|
| 13 |
+
},
|
| 14 |
+
{
|
| 15 |
+
"name": "am-ET-MekdesNeural",
|
| 16 |
+
"gender": "Female"
|
| 17 |
+
},
|
| 18 |
+
{
|
| 19 |
+
"name": "ar-AE-FatimaNeural",
|
| 20 |
+
"gender": "Female"
|
| 21 |
+
},
|
| 22 |
+
{
|
| 23 |
+
"name": "ar-AE-HamdanNeural",
|
| 24 |
+
"gender": "Male"
|
| 25 |
+
},
|
| 26 |
+
{
|
| 27 |
+
"name": "ar-BH-AliNeural",
|
| 28 |
+
"gender": "Male"
|
| 29 |
+
},
|
| 30 |
+
{
|
| 31 |
+
"name": "ar-BH-LailaNeural",
|
| 32 |
+
"gender": "Female"
|
| 33 |
+
},
|
| 34 |
+
{
|
| 35 |
+
"name": "ar-DZ-AminaNeural",
|
| 36 |
+
"gender": "Female"
|
| 37 |
+
},
|
| 38 |
+
{
|
| 39 |
+
"name": "ar-DZ-IsmaelNeural",
|
| 40 |
+
"gender": "Male"
|
| 41 |
+
},
|
| 42 |
+
{
|
| 43 |
+
"name": "ar-EG-SalmaNeural",
|
| 44 |
+
"gender": "Female"
|
| 45 |
+
},
|
| 46 |
+
{
|
| 47 |
+
"name": "ar-EG-ShakirNeural",
|
| 48 |
+
"gender": "Male"
|
| 49 |
+
},
|
| 50 |
+
{
|
| 51 |
+
"name": "ar-IQ-BasselNeural",
|
| 52 |
+
"gender": "Male"
|
| 53 |
+
},
|
| 54 |
+
{
|
| 55 |
+
"name": "ar-IQ-RanaNeural",
|
| 56 |
+
"gender": "Female"
|
| 57 |
+
},
|
| 58 |
+
{
|
| 59 |
+
"name": "ar-JO-SanaNeural",
|
| 60 |
+
"gender": "Female"
|
| 61 |
+
},
|
| 62 |
+
{
|
| 63 |
+
"name": "ar-JO-TaimNeural",
|
| 64 |
+
"gender": "Male"
|
| 65 |
+
},
|
| 66 |
+
{
|
| 67 |
+
"name": "ar-KW-FahedNeural",
|
| 68 |
+
"gender": "Male"
|
| 69 |
+
},
|
| 70 |
+
{
|
| 71 |
+
"name": "ar-KW-NouraNeural",
|
| 72 |
+
"gender": "Female"
|
| 73 |
+
},
|
| 74 |
+
{
|
| 75 |
+
"name": "ar-LB-LaylaNeural",
|
| 76 |
+
"gender": "Female"
|
| 77 |
+
},
|
| 78 |
+
{
|
| 79 |
+
"name": "ar-LB-RamiNeural",
|
| 80 |
+
"gender": "Male"
|
| 81 |
+
},
|
| 82 |
+
{
|
| 83 |
+
"name": "ar-LY-ImanNeural",
|
| 84 |
+
"gender": "Female"
|
| 85 |
+
},
|
| 86 |
+
{
|
| 87 |
+
"name": "ar-LY-OmarNeural",
|
| 88 |
+
"gender": "Male"
|
| 89 |
+
},
|
| 90 |
+
{
|
| 91 |
+
"name": "ar-MA-JamalNeural",
|
| 92 |
+
"gender": "Male"
|
| 93 |
+
},
|
| 94 |
+
{
|
| 95 |
+
"name": "ar-MA-MounaNeural",
|
| 96 |
+
"gender": "Female"
|
| 97 |
+
},
|
| 98 |
+
{
|
| 99 |
+
"name": "ar-OM-AbdullahNeural",
|
| 100 |
+
"gender": "Male"
|
| 101 |
+
},
|
| 102 |
+
{
|
| 103 |
+
"name": "ar-OM-AyshaNeural",
|
| 104 |
+
"gender": "Female"
|
| 105 |
+
},
|
| 106 |
+
{
|
| 107 |
+
"name": "ar-QA-AmalNeural",
|
| 108 |
+
"gender": "Female"
|
| 109 |
+
},
|
| 110 |
+
{
|
| 111 |
+
"name": "ar-QA-MoazNeural",
|
| 112 |
+
"gender": "Male"
|
| 113 |
+
},
|
| 114 |
+
{
|
| 115 |
+
"name": "ar-SA-HamedNeural",
|
| 116 |
+
"gender": "Male"
|
| 117 |
+
},
|
| 118 |
+
{
|
| 119 |
+
"name": "ar-SA-ZariyahNeural",
|
| 120 |
+
"gender": "Female"
|
| 121 |
+
},
|
| 122 |
+
{
|
| 123 |
+
"name": "ar-SY-AmanyNeural",
|
| 124 |
+
"gender": "Female"
|
| 125 |
+
},
|
| 126 |
+
{
|
| 127 |
+
"name": "ar-SY-LaithNeural",
|
| 128 |
+
"gender": "Male"
|
| 129 |
+
},
|
| 130 |
+
{
|
| 131 |
+
"name": "ar-TN-HediNeural",
|
| 132 |
+
"gender": "Male"
|
| 133 |
+
},
|
| 134 |
+
{
|
| 135 |
+
"name": "ar-TN-ReemNeural",
|
| 136 |
+
"gender": "Female"
|
| 137 |
+
},
|
| 138 |
+
{
|
| 139 |
+
"name": "ar-YE-MaryamNeural",
|
| 140 |
+
"gender": "Female"
|
| 141 |
+
},
|
| 142 |
+
{
|
| 143 |
+
"name": "ar-YE-SalehNeural",
|
| 144 |
+
"gender": "Male"
|
| 145 |
+
},
|
| 146 |
+
{
|
| 147 |
+
"name": "az-AZ-BabekNeural",
|
| 148 |
+
"gender": "Male"
|
| 149 |
+
},
|
| 150 |
+
{
|
| 151 |
+
"name": "az-AZ-BanuNeural",
|
| 152 |
+
"gender": "Female"
|
| 153 |
+
},
|
| 154 |
+
{
|
| 155 |
+
"name": "bg-BG-BorislavNeural",
|
| 156 |
+
"gender": "Male"
|
| 157 |
+
},
|
| 158 |
+
{
|
| 159 |
+
"name": "bg-BG-KalinaNeural",
|
| 160 |
+
"gender": "Female"
|
| 161 |
+
},
|
| 162 |
+
{
|
| 163 |
+
"name": "bn-BD-NabanitaNeural",
|
| 164 |
+
"gender": "Female"
|
| 165 |
+
},
|
| 166 |
+
{
|
| 167 |
+
"name": "bn-BD-PradeepNeural",
|
| 168 |
+
"gender": "Male"
|
| 169 |
+
},
|
| 170 |
+
{
|
| 171 |
+
"name": "bn-IN-BashkarNeural",
|
| 172 |
+
"gender": "Male"
|
| 173 |
+
},
|
| 174 |
+
{
|
| 175 |
+
"name": "bn-IN-TanishaaNeural",
|
| 176 |
+
"gender": "Female"
|
| 177 |
+
},
|
| 178 |
+
{
|
| 179 |
+
"name": "bs-BA-GoranNeural",
|
| 180 |
+
"gender": "Male"
|
| 181 |
+
},
|
| 182 |
+
{
|
| 183 |
+
"name": "bs-BA-VesnaNeural",
|
| 184 |
+
"gender": "Female"
|
| 185 |
+
},
|
| 186 |
+
{
|
| 187 |
+
"name": "ca-ES-EnricNeural",
|
| 188 |
+
"gender": "Male"
|
| 189 |
+
},
|
| 190 |
+
{
|
| 191 |
+
"name": "ca-ES-JoanaNeural",
|
| 192 |
+
"gender": "Female"
|
| 193 |
+
},
|
| 194 |
+
{
|
| 195 |
+
"name": "cs-CZ-AntoninNeural",
|
| 196 |
+
"gender": "Male"
|
| 197 |
+
},
|
| 198 |
+
{
|
| 199 |
+
"name": "cs-CZ-VlastaNeural",
|
| 200 |
+
"gender": "Female"
|
| 201 |
+
},
|
| 202 |
+
{
|
| 203 |
+
"name": "cy-GB-AledNeural",
|
| 204 |
+
"gender": "Male"
|
| 205 |
+
},
|
| 206 |
+
{
|
| 207 |
+
"name": "cy-GB-NiaNeural",
|
| 208 |
+
"gender": "Female"
|
| 209 |
+
},
|
| 210 |
+
{
|
| 211 |
+
"name": "da-DK-ChristelNeural",
|
| 212 |
+
"gender": "Female"
|
| 213 |
+
},
|
| 214 |
+
{
|
| 215 |
+
"name": "da-DK-JeppeNeural",
|
| 216 |
+
"gender": "Male"
|
| 217 |
+
},
|
| 218 |
+
{
|
| 219 |
+
"name": "de-AT-IngridNeural",
|
| 220 |
+
"gender": "Female"
|
| 221 |
+
},
|
| 222 |
+
{
|
| 223 |
+
"name": "de-AT-JonasNeural",
|
| 224 |
+
"gender": "Male"
|
| 225 |
+
},
|
| 226 |
+
{
|
| 227 |
+
"name": "de-CH-JanNeural",
|
| 228 |
+
"gender": "Male"
|
| 229 |
+
},
|
| 230 |
+
{
|
| 231 |
+
"name": "de-CH-LeniNeural",
|
| 232 |
+
"gender": "Female"
|
| 233 |
+
},
|
| 234 |
+
{
|
| 235 |
+
"name": "de-DE-AmalaNeural",
|
| 236 |
+
"gender": "Female"
|
| 237 |
+
},
|
| 238 |
+
{
|
| 239 |
+
"name": "de-DE-ConradNeural",
|
| 240 |
+
"gender": "Male"
|
| 241 |
+
},
|
| 242 |
+
{
|
| 243 |
+
"name": "de-DE-FlorianMultilingualNeural",
|
| 244 |
+
"gender": "Male"
|
| 245 |
+
},
|
| 246 |
+
{
|
| 247 |
+
"name": "de-DE-KatjaNeural",
|
| 248 |
+
"gender": "Female"
|
| 249 |
+
},
|
| 250 |
+
{
|
| 251 |
+
"name": "de-DE-KillianNeural",
|
| 252 |
+
"gender": "Male"
|
| 253 |
+
},
|
| 254 |
+
{
|
| 255 |
+
"name": "de-DE-SeraphinaMultilingualNeural",
|
| 256 |
+
"gender": "Female"
|
| 257 |
+
},
|
| 258 |
+
{
|
| 259 |
+
"name": "el-GR-AthinaNeural",
|
| 260 |
+
"gender": "Female"
|
| 261 |
+
},
|
| 262 |
+
{
|
| 263 |
+
"name": "el-GR-NestorasNeural",
|
| 264 |
+
"gender": "Male"
|
| 265 |
+
},
|
| 266 |
+
{
|
| 267 |
+
"name": "en-AU-NatashaNeural",
|
| 268 |
+
"gender": "Female"
|
| 269 |
+
},
|
| 270 |
+
{
|
| 271 |
+
"name": "en-AU-WilliamNeural",
|
| 272 |
+
"gender": "Male"
|
| 273 |
+
},
|
| 274 |
+
{
|
| 275 |
+
"name": "en-CA-ClaraNeural",
|
| 276 |
+
"gender": "Female"
|
| 277 |
+
},
|
| 278 |
+
{
|
| 279 |
+
"name": "en-CA-LiamNeural",
|
| 280 |
+
"gender": "Male"
|
| 281 |
+
},
|
| 282 |
+
{
|
| 283 |
+
"name": "en-GB-LibbyNeural",
|
| 284 |
+
"gender": "Female"
|
| 285 |
+
},
|
| 286 |
+
{
|
| 287 |
+
"name": "en-GB-MaisieNeural",
|
| 288 |
+
"gender": "Female"
|
| 289 |
+
},
|
| 290 |
+
{
|
| 291 |
+
"name": "en-GB-RyanNeural",
|
| 292 |
+
"gender": "Male"
|
| 293 |
+
},
|
| 294 |
+
{
|
| 295 |
+
"name": "en-GB-SoniaNeural",
|
| 296 |
+
"gender": "Female"
|
| 297 |
+
},
|
| 298 |
+
{
|
| 299 |
+
"name": "en-GB-ThomasNeural",
|
| 300 |
+
"gender": "Male"
|
| 301 |
+
},
|
| 302 |
+
{
|
| 303 |
+
"name": "en-HK-SamNeural",
|
| 304 |
+
"gender": "Male"
|
| 305 |
+
},
|
| 306 |
+
{
|
| 307 |
+
"name": "en-HK-YanNeural",
|
| 308 |
+
"gender": "Female"
|
| 309 |
+
},
|
| 310 |
+
{
|
| 311 |
+
"name": "en-IE-ConnorNeural",
|
| 312 |
+
"gender": "Male"
|
| 313 |
+
},
|
| 314 |
+
{
|
| 315 |
+
"name": "en-IE-EmilyNeural",
|
| 316 |
+
"gender": "Female"
|
| 317 |
+
},
|
| 318 |
+
{
|
| 319 |
+
"name": "en-IN-NeerjaExpressiveNeural",
|
| 320 |
+
"gender": "Female"
|
| 321 |
+
},
|
| 322 |
+
{
|
| 323 |
+
"name": "en-IN-NeerjaNeural",
|
| 324 |
+
"gender": "Female"
|
| 325 |
+
},
|
| 326 |
+
{
|
| 327 |
+
"name": "en-IN-PrabhatNeural",
|
| 328 |
+
"gender": "Male"
|
| 329 |
+
},
|
| 330 |
+
{
|
| 331 |
+
"name": "en-KE-AsiliaNeural",
|
| 332 |
+
"gender": "Female"
|
| 333 |
+
},
|
| 334 |
+
{
|
| 335 |
+
"name": "en-KE-ChilembaNeural",
|
| 336 |
+
"gender": "Male"
|
| 337 |
+
},
|
| 338 |
+
{
|
| 339 |
+
"name": "en-NG-AbeoNeural",
|
| 340 |
+
"gender": "Male"
|
| 341 |
+
},
|
| 342 |
+
{
|
| 343 |
+
"name": "en-NG-EzinneNeural",
|
| 344 |
+
"gender": "Female"
|
| 345 |
+
},
|
| 346 |
+
{
|
| 347 |
+
"name": "en-NZ-MitchellNeural",
|
| 348 |
+
"gender": "Male"
|
| 349 |
+
},
|
| 350 |
+
{
|
| 351 |
+
"name": "en-NZ-MollyNeural",
|
| 352 |
+
"gender": "Female"
|
| 353 |
+
},
|
| 354 |
+
{
|
| 355 |
+
"name": "en-PH-JamesNeural",
|
| 356 |
+
"gender": "Male"
|
| 357 |
+
},
|
| 358 |
+
{
|
| 359 |
+
"name": "en-PH-RosaNeural",
|
| 360 |
+
"gender": "Female"
|
| 361 |
+
},
|
| 362 |
+
{
|
| 363 |
+
"name": "en-SG-LunaNeural",
|
| 364 |
+
"gender": "Female"
|
| 365 |
+
},
|
| 366 |
+
{
|
| 367 |
+
"name": "en-SG-WayneNeural",
|
| 368 |
+
"gender": "Male"
|
| 369 |
+
},
|
| 370 |
+
{
|
| 371 |
+
"name": "en-TZ-ElimuNeural",
|
| 372 |
+
"gender": "Male"
|
| 373 |
+
},
|
| 374 |
+
{
|
| 375 |
+
"name": "en-TZ-ImaniNeural",
|
| 376 |
+
"gender": "Female"
|
| 377 |
+
},
|
| 378 |
+
{
|
| 379 |
+
"name": "en-US-AnaNeural",
|
| 380 |
+
"gender": "Female"
|
| 381 |
+
},
|
| 382 |
+
{
|
| 383 |
+
"name": "en-US-AndrewMultilingualNeural",
|
| 384 |
+
"gender": "Male"
|
| 385 |
+
},
|
| 386 |
+
{
|
| 387 |
+
"name": "en-US-AndrewNeural",
|
| 388 |
+
"gender": "Male"
|
| 389 |
+
},
|
| 390 |
+
{
|
| 391 |
+
"name": "en-US-AriaNeural",
|
| 392 |
+
"gender": "Female"
|
| 393 |
+
},
|
| 394 |
+
{
|
| 395 |
+
"name": "en-US-AvaMultilingualNeural",
|
| 396 |
+
"gender": "Female"
|
| 397 |
+
},
|
| 398 |
+
{
|
| 399 |
+
"name": "en-US-AvaNeural",
|
| 400 |
+
"gender": "Female"
|
| 401 |
+
},
|
| 402 |
+
{
|
| 403 |
+
"name": "en-US-BrianMultilingualNeural",
|
| 404 |
+
"gender": "Male"
|
| 405 |
+
},
|
| 406 |
+
{
|
| 407 |
+
"name": "en-US-BrianNeural",
|
| 408 |
+
"gender": "Male"
|
| 409 |
+
},
|
| 410 |
+
{
|
| 411 |
+
"name": "en-US-ChristopherNeural",
|
| 412 |
+
"gender": "Male"
|
| 413 |
+
},
|
| 414 |
+
{
|
| 415 |
+
"name": "en-US-EmmaMultilingualNeural",
|
| 416 |
+
"gender": "Female"
|
| 417 |
+
},
|
| 418 |
+
{
|
| 419 |
+
"name": "en-US-EmmaNeural",
|
| 420 |
+
"gender": "Female"
|
| 421 |
+
},
|
| 422 |
+
{
|
| 423 |
+
"name": "en-US-EricNeural",
|
| 424 |
+
"gender": "Male"
|
| 425 |
+
},
|
| 426 |
+
{
|
| 427 |
+
"name": "en-US-GuyNeural",
|
| 428 |
+
"gender": "Male"
|
| 429 |
+
},
|
| 430 |
+
{
|
| 431 |
+
"name": "en-US-JennyNeural",
|
| 432 |
+
"gender": "Female"
|
| 433 |
+
},
|
| 434 |
+
{
|
| 435 |
+
"name": "en-US-MichelleNeural",
|
| 436 |
+
"gender": "Female"
|
| 437 |
+
},
|
| 438 |
+
{
|
| 439 |
+
"name": "en-US-RogerNeural",
|
| 440 |
+
"gender": "Male"
|
| 441 |
+
},
|
| 442 |
+
{
|
| 443 |
+
"name": "en-US-SteffanNeural",
|
| 444 |
+
"gender": "Male"
|
| 445 |
+
},
|
| 446 |
+
{
|
| 447 |
+
"name": "en-ZA-LeahNeural",
|
| 448 |
+
"gender": "Female"
|
| 449 |
+
},
|
| 450 |
+
{
|
| 451 |
+
"name": "en-ZA-LukeNeural",
|
| 452 |
+
"gender": "Male"
|
| 453 |
+
},
|
| 454 |
+
{
|
| 455 |
+
"name": "es-AR-ElenaNeural",
|
| 456 |
+
"gender": "Female"
|
| 457 |
+
},
|
| 458 |
+
{
|
| 459 |
+
"name": "es-AR-TomasNeural",
|
| 460 |
+
"gender": "Male"
|
| 461 |
+
},
|
| 462 |
+
{
|
| 463 |
+
"name": "es-BO-MarceloNeural",
|
| 464 |
+
"gender": "Male"
|
| 465 |
+
},
|
| 466 |
+
{
|
| 467 |
+
"name": "es-BO-SofiaNeural",
|
| 468 |
+
"gender": "Female"
|
| 469 |
+
},
|
| 470 |
+
{
|
| 471 |
+
"name": "es-CL-CatalinaNeural",
|
| 472 |
+
"gender": "Female"
|
| 473 |
+
},
|
| 474 |
+
{
|
| 475 |
+
"name": "es-CL-LorenzoNeural",
|
| 476 |
+
"gender": "Male"
|
| 477 |
+
},
|
| 478 |
+
{
|
| 479 |
+
"name": "es-CO-GonzaloNeural",
|
| 480 |
+
"gender": "Male"
|
| 481 |
+
},
|
| 482 |
+
{
|
| 483 |
+
"name": "es-CO-SalomeNeural",
|
| 484 |
+
"gender": "Female"
|
| 485 |
+
},
|
| 486 |
+
{
|
| 487 |
+
"name": "es-CR-JuanNeural",
|
| 488 |
+
"gender": "Male"
|
| 489 |
+
},
|
| 490 |
+
{
|
| 491 |
+
"name": "es-CR-MariaNeural",
|
| 492 |
+
"gender": "Female"
|
| 493 |
+
},
|
| 494 |
+
{
|
| 495 |
+
"name": "es-CU-BelkysNeural",
|
| 496 |
+
"gender": "Female"
|
| 497 |
+
},
|
| 498 |
+
{
|
| 499 |
+
"name": "es-CU-ManuelNeural",
|
| 500 |
+
"gender": "Male"
|
| 501 |
+
},
|
| 502 |
+
{
|
| 503 |
+
"name": "es-DO-EmilioNeural",
|
| 504 |
+
"gender": "Male"
|
| 505 |
+
},
|
| 506 |
+
{
|
| 507 |
+
"name": "es-DO-RamonaNeural",
|
| 508 |
+
"gender": "Female"
|
| 509 |
+
},
|
| 510 |
+
{
|
| 511 |
+
"name": "es-EC-AndreaNeural",
|
| 512 |
+
"gender": "Female"
|
| 513 |
+
},
|
| 514 |
+
{
|
| 515 |
+
"name": "es-EC-LuisNeural",
|
| 516 |
+
"gender": "Male"
|
| 517 |
+
},
|
| 518 |
+
{
|
| 519 |
+
"name": "es-ES-AlvaroNeural",
|
| 520 |
+
"gender": "Male"
|
| 521 |
+
},
|
| 522 |
+
{
|
| 523 |
+
"name": "es-ES-ElviraNeural",
|
| 524 |
+
"gender": "Female"
|
| 525 |
+
},
|
| 526 |
+
{
|
| 527 |
+
"name": "es-ES-XimenaNeural",
|
| 528 |
+
"gender": "Female"
|
| 529 |
+
},
|
| 530 |
+
{
|
| 531 |
+
"name": "es-GQ-JavierNeural",
|
| 532 |
+
"gender": "Male"
|
| 533 |
+
},
|
| 534 |
+
{
|
| 535 |
+
"name": "es-GQ-TeresaNeural",
|
| 536 |
+
"gender": "Female"
|
| 537 |
+
},
|
| 538 |
+
{
|
| 539 |
+
"name": "es-GT-AndresNeural",
|
| 540 |
+
"gender": "Male"
|
| 541 |
+
},
|
| 542 |
+
{
|
| 543 |
+
"name": "es-GT-MartaNeural",
|
| 544 |
+
"gender": "Female"
|
| 545 |
+
},
|
| 546 |
+
{
|
| 547 |
+
"name": "es-HN-CarlosNeural",
|
| 548 |
+
"gender": "Male"
|
| 549 |
+
},
|
| 550 |
+
{
|
| 551 |
+
"name": "es-HN-KarlaNeural",
|
| 552 |
+
"gender": "Female"
|
| 553 |
+
},
|
| 554 |
+
{
|
| 555 |
+
"name": "es-MX-DaliaNeural",
|
| 556 |
+
"gender": "Female"
|
| 557 |
+
},
|
| 558 |
+
{
|
| 559 |
+
"name": "es-MX-JorgeNeural",
|
| 560 |
+
"gender": "Male"
|
| 561 |
+
},
|
| 562 |
+
{
|
| 563 |
+
"name": "es-NI-FedericoNeural",
|
| 564 |
+
"gender": "Male"
|
| 565 |
+
},
|
| 566 |
+
{
|
| 567 |
+
"name": "es-NI-YolandaNeural",
|
| 568 |
+
"gender": "Female"
|
| 569 |
+
},
|
| 570 |
+
{
|
| 571 |
+
"name": "es-PA-MargaritaNeural",
|
| 572 |
+
"gender": "Female"
|
| 573 |
+
},
|
| 574 |
+
{
|
| 575 |
+
"name": "es-PA-RobertoNeural",
|
| 576 |
+
"gender": "Male"
|
| 577 |
+
},
|
| 578 |
+
{
|
| 579 |
+
"name": "es-PE-AlexNeural",
|
| 580 |
+
"gender": "Male"
|
| 581 |
+
},
|
| 582 |
+
{
|
| 583 |
+
"name": "es-PE-CamilaNeural",
|
| 584 |
+
"gender": "Female"
|
| 585 |
+
},
|
| 586 |
+
{
|
| 587 |
+
"name": "es-PR-KarinaNeural",
|
| 588 |
+
"gender": "Female"
|
| 589 |
+
},
|
| 590 |
+
{
|
| 591 |
+
"name": "es-PR-VictorNeural",
|
| 592 |
+
"gender": "Male"
|
| 593 |
+
},
|
| 594 |
+
{
|
| 595 |
+
"name": "es-PY-MarioNeural",
|
| 596 |
+
"gender": "Male"
|
| 597 |
+
},
|
| 598 |
+
{
|
| 599 |
+
"name": "es-PY-TaniaNeural",
|
| 600 |
+
"gender": "Female"
|
| 601 |
+
},
|
| 602 |
+
{
|
| 603 |
+
"name": "es-SV-LorenaNeural",
|
| 604 |
+
"gender": "Female"
|
| 605 |
+
},
|
| 606 |
+
{
|
| 607 |
+
"name": "es-SV-RodrigoNeural",
|
| 608 |
+
"gender": "Male"
|
| 609 |
+
},
|
| 610 |
+
{
|
| 611 |
+
"name": "es-US-AlonsoNeural",
|
| 612 |
+
"gender": "Male"
|
| 613 |
+
},
|
| 614 |
+
{
|
| 615 |
+
"name": "es-US-PalomaNeural",
|
| 616 |
+
"gender": "Female"
|
| 617 |
+
},
|
| 618 |
+
{
|
| 619 |
+
"name": "es-UY-MateoNeural",
|
| 620 |
+
"gender": "Male"
|
| 621 |
+
},
|
| 622 |
+
{
|
| 623 |
+
"name": "es-UY-ValentinaNeural",
|
| 624 |
+
"gender": "Female"
|
| 625 |
+
},
|
| 626 |
+
{
|
| 627 |
+
"name": "es-VE-PaolaNeural",
|
| 628 |
+
"gender": "Female"
|
| 629 |
+
},
|
| 630 |
+
{
|
| 631 |
+
"name": "es-VE-SebastianNeural",
|
| 632 |
+
"gender": "Male"
|
| 633 |
+
},
|
| 634 |
+
{
|
| 635 |
+
"name": "et-EE-AnuNeural",
|
| 636 |
+
"gender": "Female"
|
| 637 |
+
},
|
| 638 |
+
{
|
| 639 |
+
"name": "et-EE-KertNeural",
|
| 640 |
+
"gender": "Male"
|
| 641 |
+
},
|
| 642 |
+
{
|
| 643 |
+
"name": "fa-IR-DilaraNeural",
|
| 644 |
+
"gender": "Female"
|
| 645 |
+
},
|
| 646 |
+
{
|
| 647 |
+
"name": "fa-IR-FaridNeural",
|
| 648 |
+
"gender": "Male"
|
| 649 |
+
},
|
| 650 |
+
{
|
| 651 |
+
"name": "fi-FI-HarriNeural",
|
| 652 |
+
"gender": "Male"
|
| 653 |
+
},
|
| 654 |
+
{
|
| 655 |
+
"name": "fi-FI-NooraNeural",
|
| 656 |
+
"gender": "Female"
|
| 657 |
+
},
|
| 658 |
+
{
|
| 659 |
+
"name": "fil-PH-AngeloNeural",
|
| 660 |
+
"gender": "Male"
|
| 661 |
+
},
|
| 662 |
+
{
|
| 663 |
+
"name": "fil-PH-BlessicaNeural",
|
| 664 |
+
"gender": "Female"
|
| 665 |
+
},
|
| 666 |
+
{
|
| 667 |
+
"name": "fr-BE-CharlineNeural",
|
| 668 |
+
"gender": "Female"
|
| 669 |
+
},
|
| 670 |
+
{
|
| 671 |
+
"name": "fr-BE-GerardNeural",
|
| 672 |
+
"gender": "Male"
|
| 673 |
+
},
|
| 674 |
+
{
|
| 675 |
+
"name": "fr-CA-AntoineNeural",
|
| 676 |
+
"gender": "Male"
|
| 677 |
+
},
|
| 678 |
+
{
|
| 679 |
+
"name": "fr-CA-JeanNeural",
|
| 680 |
+
"gender": "Male"
|
| 681 |
+
},
|
| 682 |
+
{
|
| 683 |
+
"name": "fr-CA-SylvieNeural",
|
| 684 |
+
"gender": "Female"
|
| 685 |
+
},
|
| 686 |
+
{
|
| 687 |
+
"name": "fr-CA-ThierryNeural",
|
| 688 |
+
"gender": "Male"
|
| 689 |
+
},
|
| 690 |
+
{
|
| 691 |
+
"name": "fr-CH-ArianeNeural",
|
| 692 |
+
"gender": "Female"
|
| 693 |
+
},
|
| 694 |
+
{
|
| 695 |
+
"name": "fr-CH-FabriceNeural",
|
| 696 |
+
"gender": "Male"
|
| 697 |
+
},
|
| 698 |
+
{
|
| 699 |
+
"name": "fr-FR-DeniseNeural",
|
| 700 |
+
"gender": "Female"
|
| 701 |
+
},
|
| 702 |
+
{
|
| 703 |
+
"name": "fr-FR-EloiseNeural",
|
| 704 |
+
"gender": "Female"
|
| 705 |
+
},
|
| 706 |
+
{
|
| 707 |
+
"name": "fr-FR-HenriNeural",
|
| 708 |
+
"gender": "Male"
|
| 709 |
+
},
|
| 710 |
+
{
|
| 711 |
+
"name": "fr-FR-RemyMultilingualNeural",
|
| 712 |
+
"gender": "Male"
|
| 713 |
+
},
|
| 714 |
+
{
|
| 715 |
+
"name": "fr-FR-VivienneMultilingualNeural",
|
| 716 |
+
"gender": "Female"
|
| 717 |
+
},
|
| 718 |
+
{
|
| 719 |
+
"name": "ga-IE-ColmNeural",
|
| 720 |
+
"gender": "Male"
|
| 721 |
+
},
|
| 722 |
+
{
|
| 723 |
+
"name": "ga-IE-OrlaNeural",
|
| 724 |
+
"gender": "Female"
|
| 725 |
+
},
|
| 726 |
+
{
|
| 727 |
+
"name": "gl-ES-RoiNeural",
|
| 728 |
+
"gender": "Male"
|
| 729 |
+
},
|
| 730 |
+
{
|
| 731 |
+
"name": "gl-ES-SabelaNeural",
|
| 732 |
+
"gender": "Female"
|
| 733 |
+
},
|
| 734 |
+
{
|
| 735 |
+
"name": "gu-IN-DhwaniNeural",
|
| 736 |
+
"gender": "Female"
|
| 737 |
+
},
|
| 738 |
+
{
|
| 739 |
+
"name": "gu-IN-NiranjanNeural",
|
| 740 |
+
"gender": "Male"
|
| 741 |
+
},
|
| 742 |
+
{
|
| 743 |
+
"name": "he-IL-AvriNeural",
|
| 744 |
+
"gender": "Male"
|
| 745 |
+
},
|
| 746 |
+
{
|
| 747 |
+
"name": "he-IL-HilaNeural",
|
| 748 |
+
"gender": "Female"
|
| 749 |
+
},
|
| 750 |
+
{
|
| 751 |
+
"name": "hi-IN-MadhurNeural",
|
| 752 |
+
"gender": "Male"
|
| 753 |
+
},
|
| 754 |
+
{
|
| 755 |
+
"name": "hi-IN-SwaraNeural",
|
| 756 |
+
"gender": "Female"
|
| 757 |
+
},
|
| 758 |
+
{
|
| 759 |
+
"name": "hr-HR-GabrijelaNeural",
|
| 760 |
+
"gender": "Female"
|
| 761 |
+
},
|
| 762 |
+
{
|
| 763 |
+
"name": "hr-HR-SreckoNeural",
|
| 764 |
+
"gender": "Male"
|
| 765 |
+
},
|
| 766 |
+
{
|
| 767 |
+
"name": "hu-HU-NoemiNeural",
|
| 768 |
+
"gender": "Female"
|
| 769 |
+
},
|
| 770 |
+
{
|
| 771 |
+
"name": "hu-HU-TamasNeural",
|
| 772 |
+
"gender": "Male"
|
| 773 |
+
},
|
| 774 |
+
{
|
| 775 |
+
"name": "id-ID-ArdiNeural",
|
| 776 |
+
"gender": "Male"
|
| 777 |
+
},
|
| 778 |
+
{
|
| 779 |
+
"name": "id-ID-GadisNeural",
|
| 780 |
+
"gender": "Female"
|
| 781 |
+
},
|
| 782 |
+
{
|
| 783 |
+
"name": "is-IS-GudrunNeural",
|
| 784 |
+
"gender": "Female"
|
| 785 |
+
},
|
| 786 |
+
{
|
| 787 |
+
"name": "is-IS-GunnarNeural",
|
| 788 |
+
"gender": "Male"
|
| 789 |
+
},
|
| 790 |
+
{
|
| 791 |
+
"name": "it-IT-DiegoNeural",
|
| 792 |
+
"gender": "Male"
|
| 793 |
+
},
|
| 794 |
+
{
|
| 795 |
+
"name": "it-IT-ElsaNeural",
|
| 796 |
+
"gender": "Female"
|
| 797 |
+
},
|
| 798 |
+
{
|
| 799 |
+
"name": "it-IT-GiuseppeMultilingualNeural",
|
| 800 |
+
"gender": "Male"
|
| 801 |
+
},
|
| 802 |
+
{
|
| 803 |
+
"name": "it-IT-IsabellaNeural",
|
| 804 |
+
"gender": "Female"
|
| 805 |
+
},
|
| 806 |
+
{
|
| 807 |
+
"name": "iu-Cans-CA-SiqiniqNeural",
|
| 808 |
+
"gender": "Female"
|
| 809 |
+
},
|
| 810 |
+
{
|
| 811 |
+
"name": "iu-Cans-CA-TaqqiqNeural",
|
| 812 |
+
"gender": "Male"
|
| 813 |
+
},
|
| 814 |
+
{
|
| 815 |
+
"name": "iu-Latn-CA-SiqiniqNeural",
|
| 816 |
+
"gender": "Female"
|
| 817 |
+
},
|
| 818 |
+
{
|
| 819 |
+
"name": "iu-Latn-CA-TaqqiqNeural",
|
| 820 |
+
"gender": "Male"
|
| 821 |
+
},
|
| 822 |
+
{
|
| 823 |
+
"name": "ja-JP-KeitaNeural",
|
| 824 |
+
"gender": "Male"
|
| 825 |
+
},
|
| 826 |
+
{
|
| 827 |
+
"name": "ja-JP-NanamiNeural",
|
| 828 |
+
"gender": "Female"
|
| 829 |
+
},
|
| 830 |
+
{
|
| 831 |
+
"name": "jv-ID-DimasNeural",
|
| 832 |
+
"gender": "Male"
|
| 833 |
+
},
|
| 834 |
+
{
|
| 835 |
+
"name": "jv-ID-SitiNeural",
|
| 836 |
+
"gender": "Female"
|
| 837 |
+
},
|
| 838 |
+
{
|
| 839 |
+
"name": "ka-GE-EkaNeural",
|
| 840 |
+
"gender": "Female"
|
| 841 |
+
},
|
| 842 |
+
{
|
| 843 |
+
"name": "ka-GE-GiorgiNeural",
|
| 844 |
+
"gender": "Male"
|
| 845 |
+
},
|
| 846 |
+
{
|
| 847 |
+
"name": "kk-KZ-AigulNeural",
|
| 848 |
+
"gender": "Female"
|
| 849 |
+
},
|
| 850 |
+
{
|
| 851 |
+
"name": "kk-KZ-DauletNeural",
|
| 852 |
+
"gender": "Male"
|
| 853 |
+
},
|
| 854 |
+
{
|
| 855 |
+
"name": "km-KH-PisethNeural",
|
| 856 |
+
"gender": "Male"
|
| 857 |
+
},
|
| 858 |
+
{
|
| 859 |
+
"name": "km-KH-SreymomNeural",
|
| 860 |
+
"gender": "Female"
|
| 861 |
+
},
|
| 862 |
+
{
|
| 863 |
+
"name": "kn-IN-GaganNeural",
|
| 864 |
+
"gender": "Male"
|
| 865 |
+
},
|
| 866 |
+
{
|
| 867 |
+
"name": "kn-IN-SapnaNeural",
|
| 868 |
+
"gender": "Female"
|
| 869 |
+
},
|
| 870 |
+
{
|
| 871 |
+
"name": "ko-KR-HyunsuMultilingualNeural",
|
| 872 |
+
"gender": "Male"
|
| 873 |
+
},
|
| 874 |
+
{
|
| 875 |
+
"name": "ko-KR-InJoonNeural",
|
| 876 |
+
"gender": "Male"
|
| 877 |
+
},
|
| 878 |
+
{
|
| 879 |
+
"name": "ko-KR-SunHiNeural",
|
| 880 |
+
"gender": "Female"
|
| 881 |
+
},
|
| 882 |
+
{
|
| 883 |
+
"name": "lo-LA-ChanthavongNeural",
|
| 884 |
+
"gender": "Male"
|
| 885 |
+
},
|
| 886 |
+
{
|
| 887 |
+
"name": "lo-LA-KeomanyNeural",
|
| 888 |
+
"gender": "Female"
|
| 889 |
+
},
|
| 890 |
+
{
|
| 891 |
+
"name": "lt-LT-LeonasNeural",
|
| 892 |
+
"gender": "Male"
|
| 893 |
+
},
|
| 894 |
+
{
|
| 895 |
+
"name": "lt-LT-OnaNeural",
|
| 896 |
+
"gender": "Female"
|
| 897 |
+
},
|
| 898 |
+
{
|
| 899 |
+
"name": "lv-LV-EveritaNeural",
|
| 900 |
+
"gender": "Female"
|
| 901 |
+
},
|
| 902 |
+
{
|
| 903 |
+
"name": "lv-LV-NilsNeural",
|
| 904 |
+
"gender": "Male"
|
| 905 |
+
},
|
| 906 |
+
{
|
| 907 |
+
"name": "mk-MK-AleksandarNeural",
|
| 908 |
+
"gender": "Male"
|
| 909 |
+
},
|
| 910 |
+
{
|
| 911 |
+
"name": "mk-MK-MarijaNeural",
|
| 912 |
+
"gender": "Female"
|
| 913 |
+
},
|
| 914 |
+
{
|
| 915 |
+
"name": "ml-IN-MidhunNeural",
|
| 916 |
+
"gender": "Male"
|
| 917 |
+
},
|
| 918 |
+
{
|
| 919 |
+
"name": "ml-IN-SobhanaNeural",
|
| 920 |
+
"gender": "Female"
|
| 921 |
+
},
|
| 922 |
+
{
|
| 923 |
+
"name": "mn-MN-BataaNeural",
|
| 924 |
+
"gender": "Male"
|
| 925 |
+
},
|
| 926 |
+
{
|
| 927 |
+
"name": "mn-MN-YesuiNeural",
|
| 928 |
+
"gender": "Female"
|
| 929 |
+
},
|
| 930 |
+
{
|
| 931 |
+
"name": "mr-IN-AarohiNeural",
|
| 932 |
+
"gender": "Female"
|
| 933 |
+
},
|
| 934 |
+
{
|
| 935 |
+
"name": "mr-IN-ManoharNeural",
|
| 936 |
+
"gender": "Male"
|
| 937 |
+
},
|
| 938 |
+
{
|
| 939 |
+
"name": "ms-MY-OsmanNeural",
|
| 940 |
+
"gender": "Male"
|
| 941 |
+
},
|
| 942 |
+
{
|
| 943 |
+
"name": "ms-MY-YasminNeural",
|
| 944 |
+
"gender": "Female"
|
| 945 |
+
},
|
| 946 |
+
{
|
| 947 |
+
"name": "mt-MT-GraceNeural",
|
| 948 |
+
"gender": "Female"
|
| 949 |
+
},
|
| 950 |
+
{
|
| 951 |
+
"name": "mt-MT-JosephNeural",
|
| 952 |
+
"gender": "Male"
|
| 953 |
+
},
|
| 954 |
+
{
|
| 955 |
+
"name": "my-MM-NilarNeural",
|
| 956 |
+
"gender": "Female"
|
| 957 |
+
},
|
| 958 |
+
{
|
| 959 |
+
"name": "my-MM-ThihaNeural",
|
| 960 |
+
"gender": "Male"
|
| 961 |
+
},
|
| 962 |
+
{
|
| 963 |
+
"name": "nb-NO-FinnNeural",
|
| 964 |
+
"gender": "Male"
|
| 965 |
+
},
|
| 966 |
+
{
|
| 967 |
+
"name": "nb-NO-PernilleNeural",
|
| 968 |
+
"gender": "Female"
|
| 969 |
+
},
|
| 970 |
+
{
|
| 971 |
+
"name": "ne-NP-HemkalaNeural",
|
| 972 |
+
"gender": "Female"
|
| 973 |
+
},
|
| 974 |
+
{
|
| 975 |
+
"name": "ne-NP-SagarNeural",
|
| 976 |
+
"gender": "Male"
|
| 977 |
+
},
|
| 978 |
+
{
|
| 979 |
+
"name": "nl-BE-ArnaudNeural",
|
| 980 |
+
"gender": "Male"
|
| 981 |
+
},
|
| 982 |
+
{
|
| 983 |
+
"name": "nl-BE-DenaNeural",
|
| 984 |
+
"gender": "Female"
|
| 985 |
+
},
|
| 986 |
+
{
|
| 987 |
+
"name": "nl-NL-ColetteNeural",
|
| 988 |
+
"gender": "Female"
|
| 989 |
+
},
|
| 990 |
+
{
|
| 991 |
+
"name": "nl-NL-FennaNeural",
|
| 992 |
+
"gender": "Female"
|
| 993 |
+
},
|
| 994 |
+
{
|
| 995 |
+
"name": "nl-NL-MaartenNeural",
|
| 996 |
+
"gender": "Male"
|
| 997 |
+
},
|
| 998 |
+
{
|
| 999 |
+
"name": "pl-PL-MarekNeural",
|
| 1000 |
+
"gender": "Male"
|
| 1001 |
+
},
|
| 1002 |
+
{
|
| 1003 |
+
"name": "pl-PL-ZofiaNeural",
|
| 1004 |
+
"gender": "Female"
|
| 1005 |
+
},
|
| 1006 |
+
{
|
| 1007 |
+
"name": "ps-AF-GulNawazNeural",
|
| 1008 |
+
"gender": "Male"
|
| 1009 |
+
},
|
| 1010 |
+
{
|
| 1011 |
+
"name": "ps-AF-LatifaNeural",
|
| 1012 |
+
"gender": "Female"
|
| 1013 |
+
},
|
| 1014 |
+
{
|
| 1015 |
+
"name": "pt-BR-AntonioNeural",
|
| 1016 |
+
"gender": "Male"
|
| 1017 |
+
},
|
| 1018 |
+
{
|
| 1019 |
+
"name": "pt-BR-FranciscaNeural",
|
| 1020 |
+
"gender": "Female"
|
| 1021 |
+
},
|
| 1022 |
+
{
|
| 1023 |
+
"name": "pt-BR-ThalitaMultilingualNeural",
|
| 1024 |
+
"gender": "Female"
|
| 1025 |
+
},
|
| 1026 |
+
{
|
| 1027 |
+
"name": "pt-PT-DuarteNeural",
|
| 1028 |
+
"gender": "Male"
|
| 1029 |
+
},
|
| 1030 |
+
{
|
| 1031 |
+
"name": "pt-PT-RaquelNeural",
|
| 1032 |
+
"gender": "Female"
|
| 1033 |
+
},
|
| 1034 |
+
{
|
| 1035 |
+
"name": "ro-RO-AlinaNeural",
|
| 1036 |
+
"gender": "Female"
|
| 1037 |
+
},
|
| 1038 |
+
{
|
| 1039 |
+
"name": "ro-RO-EmilNeural",
|
| 1040 |
+
"gender": "Male"
|
| 1041 |
+
},
|
| 1042 |
+
{
|
| 1043 |
+
"name": "ru-RU-DmitryNeural",
|
| 1044 |
+
"gender": "Male"
|
| 1045 |
+
},
|
| 1046 |
+
{
|
| 1047 |
+
"name": "ru-RU-SvetlanaNeural",
|
| 1048 |
+
"gender": "Female"
|
| 1049 |
+
},
|
| 1050 |
+
{
|
| 1051 |
+
"name": "si-LK-SameeraNeural",
|
| 1052 |
+
"gender": "Male"
|
| 1053 |
+
},
|
| 1054 |
+
{
|
| 1055 |
+
"name": "si-LK-ThiliniNeural",
|
| 1056 |
+
"gender": "Female"
|
| 1057 |
+
},
|
| 1058 |
+
{
|
| 1059 |
+
"name": "sk-SK-LukasNeural",
|
| 1060 |
+
"gender": "Male"
|
| 1061 |
+
},
|
| 1062 |
+
{
|
| 1063 |
+
"name": "sk-SK-ViktoriaNeural",
|
| 1064 |
+
"gender": "Female"
|
| 1065 |
+
},
|
| 1066 |
+
{
|
| 1067 |
+
"name": "sl-SI-PetraNeural",
|
| 1068 |
+
"gender": "Female"
|
| 1069 |
+
},
|
| 1070 |
+
{
|
| 1071 |
+
"name": "sl-SI-RokNeural",
|
| 1072 |
+
"gender": "Male"
|
| 1073 |
+
},
|
| 1074 |
+
{
|
| 1075 |
+
"name": "so-SO-MuuseNeural",
|
| 1076 |
+
"gender": "Male"
|
| 1077 |
+
},
|
| 1078 |
+
{
|
| 1079 |
+
"name": "so-SO-UbaxNeural",
|
| 1080 |
+
"gender": "Female"
|
| 1081 |
+
},
|
| 1082 |
+
{
|
| 1083 |
+
"name": "sq-AL-AnilaNeural",
|
| 1084 |
+
"gender": "Female"
|
| 1085 |
+
},
|
| 1086 |
+
{
|
| 1087 |
+
"name": "sq-AL-IlirNeural",
|
| 1088 |
+
"gender": "Male"
|
| 1089 |
+
},
|
| 1090 |
+
{
|
| 1091 |
+
"name": "sr-RS-NicholasNeural",
|
| 1092 |
+
"gender": "Male"
|
| 1093 |
+
},
|
| 1094 |
+
{
|
| 1095 |
+
"name": "sr-RS-SophieNeural",
|
| 1096 |
+
"gender": "Female"
|
| 1097 |
+
},
|
| 1098 |
+
{
|
| 1099 |
+
"name": "su-ID-JajangNeural",
|
| 1100 |
+
"gender": "Male"
|
| 1101 |
+
},
|
| 1102 |
+
{
|
| 1103 |
+
"name": "su-ID-TutiNeural",
|
| 1104 |
+
"gender": "Female"
|
| 1105 |
+
},
|
| 1106 |
+
{
|
| 1107 |
+
"name": "sv-SE-MattiasNeural",
|
| 1108 |
+
"gender": "Male"
|
| 1109 |
+
},
|
| 1110 |
+
{
|
| 1111 |
+
"name": "sv-SE-SofieNeural",
|
| 1112 |
+
"gender": "Female"
|
| 1113 |
+
},
|
| 1114 |
+
{
|
| 1115 |
+
"name": "sw-KE-RafikiNeural",
|
| 1116 |
+
"gender": "Male"
|
| 1117 |
+
},
|
| 1118 |
+
{
|
| 1119 |
+
"name": "sw-KE-ZuriNeural",
|
| 1120 |
+
"gender": "Female"
|
| 1121 |
+
},
|
| 1122 |
+
{
|
| 1123 |
+
"name": "sw-TZ-DaudiNeural",
|
| 1124 |
+
"gender": "Male"
|
| 1125 |
+
},
|
| 1126 |
+
{
|
| 1127 |
+
"name": "sw-TZ-RehemaNeural",
|
| 1128 |
+
"gender": "Female"
|
| 1129 |
+
},
|
| 1130 |
+
{
|
| 1131 |
+
"name": "ta-IN-PallaviNeural",
|
| 1132 |
+
"gender": "Female"
|
| 1133 |
+
},
|
| 1134 |
+
{
|
| 1135 |
+
"name": "ta-IN-ValluvarNeural",
|
| 1136 |
+
"gender": "Male"
|
| 1137 |
+
},
|
| 1138 |
+
{
|
| 1139 |
+
"name": "ta-LK-KumarNeural",
|
| 1140 |
+
"gender": "Male"
|
| 1141 |
+
},
|
| 1142 |
+
{
|
| 1143 |
+
"name": "ta-LK-SaranyaNeural",
|
| 1144 |
+
"gender": "Female"
|
| 1145 |
+
},
|
| 1146 |
+
{
|
| 1147 |
+
"name": "ta-MY-KaniNeural",
|
| 1148 |
+
"gender": "Female"
|
| 1149 |
+
},
|
| 1150 |
+
{
|
| 1151 |
+
"name": "ta-MY-SuryaNeural",
|
| 1152 |
+
"gender": "Male"
|
| 1153 |
+
},
|
| 1154 |
+
{
|
| 1155 |
+
"name": "ta-SG-AnbuNeural",
|
| 1156 |
+
"gender": "Male"
|
| 1157 |
+
},
|
| 1158 |
+
{
|
| 1159 |
+
"name": "ta-SG-VenbaNeural",
|
| 1160 |
+
"gender": "Female"
|
| 1161 |
+
},
|
| 1162 |
+
{
|
| 1163 |
+
"name": "te-IN-MohanNeural",
|
| 1164 |
+
"gender": "Male"
|
| 1165 |
+
},
|
| 1166 |
+
{
|
| 1167 |
+
"name": "te-IN-ShrutiNeural",
|
| 1168 |
+
"gender": "Female"
|
| 1169 |
+
},
|
| 1170 |
+
{
|
| 1171 |
+
"name": "th-TH-NiwatNeural",
|
| 1172 |
+
"gender": "Male"
|
| 1173 |
+
},
|
| 1174 |
+
{
|
| 1175 |
+
"name": "th-TH-PremwadeeNeural",
|
| 1176 |
+
"gender": "Female"
|
| 1177 |
+
},
|
| 1178 |
+
{
|
| 1179 |
+
"name": "tr-TR-AhmetNeural",
|
| 1180 |
+
"gender": "Male"
|
| 1181 |
+
},
|
| 1182 |
+
{
|
| 1183 |
+
"name": "tr-TR-EmelNeural",
|
| 1184 |
+
"gender": "Female"
|
| 1185 |
+
},
|
| 1186 |
+
{
|
| 1187 |
+
"name": "uk-UA-OstapNeural",
|
| 1188 |
+
"gender": "Male"
|
| 1189 |
+
},
|
| 1190 |
+
{
|
| 1191 |
+
"name": "uk-UA-PolinaNeural",
|
| 1192 |
+
"gender": "Female"
|
| 1193 |
+
},
|
| 1194 |
+
{
|
| 1195 |
+
"name": "ur-IN-GulNeural",
|
| 1196 |
+
"gender": "Female"
|
| 1197 |
+
},
|
| 1198 |
+
{
|
| 1199 |
+
"name": "ur-IN-SalmanNeural",
|
| 1200 |
+
"gender": "Male"
|
| 1201 |
+
},
|
| 1202 |
+
{
|
| 1203 |
+
"name": "ur-PK-AsadNeural",
|
| 1204 |
+
"gender": "Male"
|
| 1205 |
+
},
|
| 1206 |
+
{
|
| 1207 |
+
"name": "ur-PK-UzmaNeural",
|
| 1208 |
+
"gender": "Female"
|
| 1209 |
+
},
|
| 1210 |
+
{
|
| 1211 |
+
"name": "uz-UZ-MadinaNeural",
|
| 1212 |
+
"gender": "Female"
|
| 1213 |
+
},
|
| 1214 |
+
{
|
| 1215 |
+
"name": "uz-UZ-SardorNeural",
|
| 1216 |
+
"gender": "Male"
|
| 1217 |
+
},
|
| 1218 |
+
{
|
| 1219 |
+
"name": "vi-VN-HoaiMyNeural",
|
| 1220 |
+
"gender": "Female"
|
| 1221 |
+
},
|
| 1222 |
+
{
|
| 1223 |
+
"name": "vi-VN-NamMinhNeural",
|
| 1224 |
+
"gender": "Male"
|
| 1225 |
+
},
|
| 1226 |
+
{
|
| 1227 |
+
"name": "zh-CN-XiaoxiaoNeural",
|
| 1228 |
+
"gender": "Female"
|
| 1229 |
+
},
|
| 1230 |
+
{
|
| 1231 |
+
"name": "zh-CN-XiaoyiNeural",
|
| 1232 |
+
"gender": "Female"
|
| 1233 |
+
},
|
| 1234 |
+
{
|
| 1235 |
+
"name": "zh-CN-YunjianNeural",
|
| 1236 |
+
"gender": "Male"
|
| 1237 |
+
},
|
| 1238 |
+
{
|
| 1239 |
+
"name": "zh-CN-YunxiNeural",
|
| 1240 |
+
"gender": "Male"
|
| 1241 |
+
},
|
| 1242 |
+
{
|
| 1243 |
+
"name": "zh-CN-YunxiaNeural",
|
| 1244 |
+
"gender": "Male"
|
| 1245 |
+
},
|
| 1246 |
+
{
|
| 1247 |
+
"name": "zh-CN-YunyangNeural",
|
| 1248 |
+
"gender": "Male"
|
| 1249 |
+
},
|
| 1250 |
+
{
|
| 1251 |
+
"name": "zh-CN-liaoning-XiaobeiNeural",
|
| 1252 |
+
"gender": "Female"
|
| 1253 |
+
},
|
| 1254 |
+
{
|
| 1255 |
+
"name": "zh-CN-shaanxi-XiaoniNeural",
|
| 1256 |
+
"gender": "Female"
|
| 1257 |
+
},
|
| 1258 |
+
{
|
| 1259 |
+
"name": "zh-HK-HiuGaaiNeural",
|
| 1260 |
+
"gender": "Female"
|
| 1261 |
+
},
|
| 1262 |
+
{
|
| 1263 |
+
"name": "zh-HK-HiuMaanNeural",
|
| 1264 |
+
"gender": "Female"
|
| 1265 |
+
},
|
| 1266 |
+
{
|
| 1267 |
+
"name": "zh-HK-WanLungNeural",
|
| 1268 |
+
"gender": "Male"
|
| 1269 |
+
},
|
| 1270 |
+
{
|
| 1271 |
+
"name": "zh-TW-HsiaoChenNeural",
|
| 1272 |
+
"gender": "Female"
|
| 1273 |
+
},
|
| 1274 |
+
{
|
| 1275 |
+
"name": "zh-TW-HsiaoYuNeural",
|
| 1276 |
+
"gender": "Female"
|
| 1277 |
+
},
|
| 1278 |
+
{
|
| 1279 |
+
"name": "zh-TW-YunJheNeural",
|
| 1280 |
+
"gender": "Male"
|
| 1281 |
+
},
|
| 1282 |
+
{
|
| 1283 |
+
"name": "zu-ZA-ThandoNeural",
|
| 1284 |
+
"gender": "Female"
|
| 1285 |
+
},
|
| 1286 |
+
{
|
| 1287 |
+
"name": "zu-ZA-ThembaNeural",
|
| 1288 |
+
"gender": "Male"
|
| 1289 |
+
},
|
| 1290 |
+
{
|
| 1291 |
+
"name": "en-US-AvaMultilingualNeural-V2",
|
| 1292 |
+
"gender": "Female"
|
| 1293 |
+
},
|
| 1294 |
+
{
|
| 1295 |
+
"name": "en-US-AndrewMultilingualNeural-V2",
|
| 1296 |
+
"gender": "Male"
|
| 1297 |
+
},
|
| 1298 |
+
{
|
| 1299 |
+
"name": "en-US-EmmaMultilingualNeural-V2",
|
| 1300 |
+
"gender": "Female"
|
| 1301 |
+
},
|
| 1302 |
+
{
|
| 1303 |
+
"name": "en-US-BrianMultilingualNeural-V2",
|
| 1304 |
+
"gender": "Male"
|
| 1305 |
+
},
|
| 1306 |
+
{
|
| 1307 |
+
"name": "de-DE-FlorianMultilingualNeural-V2",
|
| 1308 |
+
"gender": "Male"
|
| 1309 |
+
},
|
| 1310 |
+
{
|
| 1311 |
+
"name": "de-DE-SeraphinaMultilingualNeural-V2",
|
| 1312 |
+
"gender": "Female"
|
| 1313 |
+
},
|
| 1314 |
+
{
|
| 1315 |
+
"name": "fr-FR-RemyMultilingualNeural-V2",
|
| 1316 |
+
"gender": "Male"
|
| 1317 |
+
},
|
| 1318 |
+
{
|
| 1319 |
+
"name": "fr-FR-VivienneMultilingualNeural-V2",
|
| 1320 |
+
"gender": "Female"
|
| 1321 |
+
},
|
| 1322 |
+
{
|
| 1323 |
+
"name": "zh-CN-XiaoxiaoMultilingualNeural-V2",
|
| 1324 |
+
"gender": "Female"
|
| 1325 |
+
}
|
| 1326 |
+
]
|
app/services/llm.py
ADDED
|
@@ -0,0 +1,725 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
import logging
|
| 3 |
+
import re
|
| 4 |
+
import requests
|
| 5 |
+
from typing import List
|
| 6 |
+
|
| 7 |
+
from loguru import logger
|
| 8 |
+
from openai import AzureOpenAI, OpenAI
|
| 9 |
+
from openai.types.chat import ChatCompletion
|
| 10 |
+
|
| 11 |
+
from app.config import config
|
| 12 |
+
|
| 13 |
+
_max_retries = 5
|
| 14 |
+
_DEFAULT_GEMINI_MODEL = "gemini-2.5-flash"
|
| 15 |
+
_DEPRECATED_GEMINI_MODELS = {"gemini-pro", "gemini-1.0-pro"}
|
| 16 |
+
MIN_SCRIPT_PARAGRAPH_NUMBER = 1
|
| 17 |
+
MAX_SCRIPT_PARAGRAPH_NUMBER = 10
|
| 18 |
+
MAX_SCRIPT_PROMPT_LENGTH = 2000
|
| 19 |
+
MAX_SCRIPT_SYSTEM_PROMPT_LENGTH = 8000
|
| 20 |
+
|
| 21 |
+
DEFAULT_SCRIPT_SYSTEM_PROMPT = """
|
| 22 |
+
# Role: Video Script Generator
|
| 23 |
+
|
| 24 |
+
## Goals:
|
| 25 |
+
Generate a script for a video, depending on the subject of the video.
|
| 26 |
+
|
| 27 |
+
## Constrains:
|
| 28 |
+
1. the script is to be returned as a string with the specified number of paragraphs.
|
| 29 |
+
2. do not under any circumstance reference this prompt in your response.
|
| 30 |
+
3. get straight to the point, don't start with unnecessary things like, "welcome to this video".
|
| 31 |
+
4. you must not include any type of markdown or formatting in the script, never use a title.
|
| 32 |
+
5. only return the raw content of the script.
|
| 33 |
+
6. do not include "voiceover", "narrator" or similar indicators of what should be spoken at the beginning of each paragraph or line.
|
| 34 |
+
7. you must not mention the prompt, or anything about the script itself. also, never talk about the amount of paragraphs or lines. just write the script.
|
| 35 |
+
8. respond in the same language as the video subject.
|
| 36 |
+
""".strip()
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def _normalize_text_response(content, llm_provider: str) -> str:
|
| 40 |
+
# 不同 LLM SDK 在异常或被拦截场景下,可能返回 None、空字符串,
|
| 41 |
+
# 甚至返回非字符串对象。这里统一做兜底校验,避免后续直接调用
|
| 42 |
+
# `.replace()` 时抛出 `NoneType` 之类的属性错误。
|
| 43 |
+
if content is None:
|
| 44 |
+
raise ValueError(f"[{llm_provider}] returned empty text content")
|
| 45 |
+
|
| 46 |
+
if not isinstance(content, str):
|
| 47 |
+
raise TypeError(
|
| 48 |
+
f"[{llm_provider}] returned non-text content: {type(content).__name__}"
|
| 49 |
+
)
|
| 50 |
+
|
| 51 |
+
content = content.strip()
|
| 52 |
+
if not content:
|
| 53 |
+
raise ValueError(f"[{llm_provider}] returned empty text content")
|
| 54 |
+
|
| 55 |
+
return content.replace("\n", "")
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def _extract_chat_completion_text(response, llm_provider: str) -> str:
|
| 59 |
+
# OpenAI 兼容接口在异常场景下,可能返回没有 choices、
|
| 60 |
+
# 或者 choices/message/content 为空的响应对象。
|
| 61 |
+
# 这里统一做结构校验,避免出现 `NoneType is not subscriptable`
|
| 62 |
+
# 这类底层属性访问错误。
|
| 63 |
+
choices = getattr(response, "choices", None)
|
| 64 |
+
if not choices:
|
| 65 |
+
raise ValueError(f"[{llm_provider}] returned empty choices")
|
| 66 |
+
|
| 67 |
+
first_choice = choices[0]
|
| 68 |
+
message = getattr(first_choice, "message", None)
|
| 69 |
+
if message is None:
|
| 70 |
+
raise ValueError(f"[{llm_provider}] returned empty message")
|
| 71 |
+
|
| 72 |
+
content = getattr(message, "content", None)
|
| 73 |
+
return _normalize_text_response(content, llm_provider)
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def _generate_response(prompt: str) -> str:
|
| 77 |
+
try:
|
| 78 |
+
content = ""
|
| 79 |
+
llm_provider = config.app.get("llm_provider", "openai")
|
| 80 |
+
logger.info(f"llm provider: {llm_provider}")
|
| 81 |
+
if llm_provider == "g4f":
|
| 82 |
+
if not config.app.get("enable_g4f", False):
|
| 83 |
+
raise ValueError(
|
| 84 |
+
"g4f provider is disabled by default because it relies on "
|
| 85 |
+
"reverse-engineered third-party endpoints. Set enable_g4f=true "
|
| 86 |
+
"in config.toml only if you understand and accept the security, "
|
| 87 |
+
"reliability, and legal risks."
|
| 88 |
+
)
|
| 89 |
+
|
| 90 |
+
logger.warning(
|
| 91 |
+
"g4f provider is enabled. This provider may be unstable and carries "
|
| 92 |
+
"supply-chain and terms-of-service risks. Prefer official providers, "
|
| 93 |
+
"OpenAI-compatible APIs, LiteLLM, Ollama, or local inference for production."
|
| 94 |
+
)
|
| 95 |
+
try:
|
| 96 |
+
import g4f
|
| 97 |
+
except ImportError as e:
|
| 98 |
+
raise ValueError(
|
| 99 |
+
"g4f package is not installed by default. Install the optional "
|
| 100 |
+
"dependency with `uv sync --extra g4f` only if you understand "
|
| 101 |
+
"and accept the provider risks."
|
| 102 |
+
) from e
|
| 103 |
+
|
| 104 |
+
model_name = config.app.get("g4f_model_name", "")
|
| 105 |
+
if not model_name:
|
| 106 |
+
model_name = "gpt-3.5-turbo-16k-0613"
|
| 107 |
+
content = g4f.ChatCompletion.create(
|
| 108 |
+
model=model_name,
|
| 109 |
+
messages=[{"role": "user", "content": prompt}],
|
| 110 |
+
)
|
| 111 |
+
else:
|
| 112 |
+
api_version = "" # for azure
|
| 113 |
+
if llm_provider == "moonshot":
|
| 114 |
+
api_key = config.app.get("moonshot_api_key")
|
| 115 |
+
model_name = config.app.get("moonshot_model_name")
|
| 116 |
+
base_url = "https://api.moonshot.cn/v1"
|
| 117 |
+
elif llm_provider == "ollama":
|
| 118 |
+
# api_key = config.app.get("openai_api_key")
|
| 119 |
+
api_key = "ollama" # any string works but you are required to have one
|
| 120 |
+
model_name = config.app.get("ollama_model_name")
|
| 121 |
+
base_url = config.app.get("ollama_base_url", "")
|
| 122 |
+
if not base_url:
|
| 123 |
+
base_url = config.get_default_ollama_base_url()
|
| 124 |
+
elif llm_provider == "openai":
|
| 125 |
+
api_key = config.app.get("openai_api_key")
|
| 126 |
+
model_name = config.app.get("openai_model_name")
|
| 127 |
+
base_url = config.app.get("openai_base_url", "")
|
| 128 |
+
if not base_url:
|
| 129 |
+
base_url = "https://api.openai.com/v1"
|
| 130 |
+
elif llm_provider == "aihubmix":
|
| 131 |
+
api_key = config.app.get("aihubmix_api_key")
|
| 132 |
+
model_name = config.app.get("aihubmix_model_name")
|
| 133 |
+
base_url = config.app.get("aihubmix_base_url", "")
|
| 134 |
+
# AIHubMix 兼容 OpenAI Chat Completions 协议。这里使用独立
|
| 135 |
+
# provider 保存合作方的默认网关和推荐模型,避免把推广链接、
|
| 136 |
+
# 默认模型等合作配置混进普通 OpenAI provider,影响现有用户。
|
| 137 |
+
if not base_url:
|
| 138 |
+
base_url = "https://aihubmix.com/v1"
|
| 139 |
+
if not model_name:
|
| 140 |
+
model_name = "gpt-5.4-mini"
|
| 141 |
+
elif llm_provider == "oneapi":
|
| 142 |
+
api_key = config.app.get("oneapi_api_key")
|
| 143 |
+
model_name = config.app.get("oneapi_model_name")
|
| 144 |
+
base_url = config.app.get("oneapi_base_url", "")
|
| 145 |
+
elif llm_provider == "azure":
|
| 146 |
+
api_key = config.app.get("azure_api_key")
|
| 147 |
+
model_name = config.app.get("azure_model_name")
|
| 148 |
+
base_url = config.app.get("azure_base_url", "")
|
| 149 |
+
api_version = config.app.get("azure_api_version", "2024-02-15-preview")
|
| 150 |
+
elif llm_provider == "gemini":
|
| 151 |
+
api_key = config.app.get("gemini_api_key")
|
| 152 |
+
model_name = config.app.get("gemini_model_name")
|
| 153 |
+
base_url = config.app.get("gemini_base_url", "")
|
| 154 |
+
# Gemini 旧模型名已经陆续下线,这里自动兼容历史配置,
|
| 155 |
+
# 避免用户沿用旧值时直接收到 404。
|
| 156 |
+
if not model_name:
|
| 157 |
+
model_name = _DEFAULT_GEMINI_MODEL
|
| 158 |
+
elif model_name in _DEPRECATED_GEMINI_MODELS:
|
| 159 |
+
logger.warning(
|
| 160 |
+
f"gemini model '{model_name}' is deprecated, fallback to '{_DEFAULT_GEMINI_MODEL}'"
|
| 161 |
+
)
|
| 162 |
+
model_name = _DEFAULT_GEMINI_MODEL
|
| 163 |
+
elif llm_provider == "grok":
|
| 164 |
+
api_key = config.app.get("grok_api_key")
|
| 165 |
+
model_name = config.app.get("grok_model_name")
|
| 166 |
+
base_url = config.app.get("grok_base_url", "")
|
| 167 |
+
if not base_url:
|
| 168 |
+
base_url = "https://api.x.ai/v1"
|
| 169 |
+
elif llm_provider == "qwen":
|
| 170 |
+
api_key = config.app.get("qwen_api_key")
|
| 171 |
+
model_name = config.app.get("qwen_model_name")
|
| 172 |
+
base_url = "***"
|
| 173 |
+
elif llm_provider == "cloudflare":
|
| 174 |
+
api_key = config.app.get("cloudflare_api_key")
|
| 175 |
+
model_name = config.app.get("cloudflare_model_name")
|
| 176 |
+
account_id = config.app.get("cloudflare_account_id")
|
| 177 |
+
base_url = "***"
|
| 178 |
+
elif llm_provider == "minimax":
|
| 179 |
+
api_key = config.app.get("minimax_api_key")
|
| 180 |
+
model_name = config.app.get("minimax_model_name")
|
| 181 |
+
base_url = config.app.get("minimax_base_url", "")
|
| 182 |
+
if not base_url:
|
| 183 |
+
base_url = "https://api.minimax.io/v1"
|
| 184 |
+
elif llm_provider == "mimo":
|
| 185 |
+
api_key = config.app.get("mimo_api_key")
|
| 186 |
+
model_name = config.app.get("mimo_model_name")
|
| 187 |
+
base_url = config.app.get("mimo_base_url", "")
|
| 188 |
+
# Xiaomi MiMo 官方文档说明其兼容 OpenAI Chat Completions 协议。
|
| 189 |
+
# 这里使用独立 provider 保存默认地址和模型名,用户不用把 MiMo
|
| 190 |
+
# 当作 OpenAI 自定义 base_url 配置,也便于后续继续接入 MiMo
|
| 191 |
+
# 多模态或 TTS 能力时保持边界清晰。
|
| 192 |
+
if not base_url:
|
| 193 |
+
base_url = "https://api.xiaomimimo.com/v1"
|
| 194 |
+
if not model_name:
|
| 195 |
+
model_name = "mimo-v2.5-pro"
|
| 196 |
+
elif llm_provider == "deepseek":
|
| 197 |
+
api_key = config.app.get("deepseek_api_key")
|
| 198 |
+
model_name = config.app.get("deepseek_model_name")
|
| 199 |
+
base_url = config.app.get("deepseek_base_url")
|
| 200 |
+
if not base_url:
|
| 201 |
+
base_url = "https://api.deepseek.com"
|
| 202 |
+
elif llm_provider == "modelscope":
|
| 203 |
+
api_key = config.app.get("modelscope_api_key")
|
| 204 |
+
model_name = config.app.get("modelscope_model_name")
|
| 205 |
+
base_url = config.app.get("modelscope_base_url")
|
| 206 |
+
if not base_url:
|
| 207 |
+
base_url = "https://api-inference.modelscope.cn/v1/"
|
| 208 |
+
elif llm_provider == "ernie":
|
| 209 |
+
api_key = config.app.get("ernie_api_key")
|
| 210 |
+
secret_key = config.app.get("ernie_secret_key")
|
| 211 |
+
base_url = config.app.get("ernie_base_url")
|
| 212 |
+
model_name = "***"
|
| 213 |
+
if not secret_key:
|
| 214 |
+
raise ValueError(
|
| 215 |
+
f"{llm_provider}: secret_key is not set, please set it in the config.toml file."
|
| 216 |
+
)
|
| 217 |
+
elif llm_provider == "pollinations":
|
| 218 |
+
try:
|
| 219 |
+
base_url = config.app.get("pollinations_base_url", "")
|
| 220 |
+
if not base_url:
|
| 221 |
+
base_url = "https://text.pollinations.ai/openai"
|
| 222 |
+
model_name = config.app.get("pollinations_model_name", "openai-fast")
|
| 223 |
+
|
| 224 |
+
# Prepare the payload
|
| 225 |
+
payload = {
|
| 226 |
+
"model": model_name,
|
| 227 |
+
"messages": [
|
| 228 |
+
{"role": "user", "content": prompt}
|
| 229 |
+
],
|
| 230 |
+
"seed": 101 # Optional but helps with reproducibility
|
| 231 |
+
}
|
| 232 |
+
|
| 233 |
+
# Optional parameters if configured
|
| 234 |
+
if config.app.get("pollinations_private"):
|
| 235 |
+
payload["private"] = True
|
| 236 |
+
if config.app.get("pollinations_referrer"):
|
| 237 |
+
payload["referrer"] = config.app.get("pollinations_referrer")
|
| 238 |
+
|
| 239 |
+
headers = {
|
| 240 |
+
"Content-Type": "application/json"
|
| 241 |
+
}
|
| 242 |
+
|
| 243 |
+
# Make the API request
|
| 244 |
+
response = requests.post(base_url, headers=headers, json=payload)
|
| 245 |
+
response.raise_for_status()
|
| 246 |
+
result = response.json()
|
| 247 |
+
|
| 248 |
+
if result and "choices" in result and len(result["choices"]) > 0:
|
| 249 |
+
content = result["choices"][0]["message"]["content"]
|
| 250 |
+
return _normalize_text_response(content, llm_provider)
|
| 251 |
+
else:
|
| 252 |
+
raise Exception(f"[{llm_provider}] returned an invalid response format")
|
| 253 |
+
|
| 254 |
+
except requests.exceptions.RequestException as e:
|
| 255 |
+
raise Exception(f"[{llm_provider}] request failed: {str(e)}")
|
| 256 |
+
except Exception as e:
|
| 257 |
+
raise Exception(f"[{llm_provider}] error: {str(e)}")
|
| 258 |
+
|
| 259 |
+
elif llm_provider == "litellm":
|
| 260 |
+
model_name = config.app.get("litellm_model_name")
|
| 261 |
+
|
| 262 |
+
if llm_provider not in ["pollinations", "ollama", "litellm"]: # Skip validation for providers that don't require API key
|
| 263 |
+
if not api_key:
|
| 264 |
+
raise ValueError(
|
| 265 |
+
f"{llm_provider}: api_key is not set, please set it in the config.toml file."
|
| 266 |
+
)
|
| 267 |
+
if not model_name:
|
| 268 |
+
raise ValueError(
|
| 269 |
+
f"{llm_provider}: model_name is not set, please set it in the config.toml file."
|
| 270 |
+
)
|
| 271 |
+
if not base_url and llm_provider not in ["gemini"]:
|
| 272 |
+
raise ValueError(
|
| 273 |
+
f"{llm_provider}: base_url is not set, please set it in the config.toml file."
|
| 274 |
+
)
|
| 275 |
+
|
| 276 |
+
if llm_provider == "qwen":
|
| 277 |
+
import dashscope
|
| 278 |
+
from dashscope.api_entities.dashscope_response import GenerationResponse
|
| 279 |
+
|
| 280 |
+
dashscope.api_key = api_key
|
| 281 |
+
response = dashscope.Generation.call(
|
| 282 |
+
model=model_name, messages=[{"role": "user", "content": prompt}]
|
| 283 |
+
)
|
| 284 |
+
if response:
|
| 285 |
+
if isinstance(response, GenerationResponse):
|
| 286 |
+
status_code = response.status_code
|
| 287 |
+
if status_code != 200:
|
| 288 |
+
raise Exception(
|
| 289 |
+
f'[{llm_provider}] returned an error response: "{response}"'
|
| 290 |
+
)
|
| 291 |
+
|
| 292 |
+
content = response["output"]["text"]
|
| 293 |
+
return content.replace("\n", "")
|
| 294 |
+
else:
|
| 295 |
+
raise Exception(
|
| 296 |
+
f'[{llm_provider}] returned an invalid response: "{response}"'
|
| 297 |
+
)
|
| 298 |
+
else:
|
| 299 |
+
raise Exception(f"[{llm_provider}] returned an empty response")
|
| 300 |
+
|
| 301 |
+
if llm_provider == "gemini":
|
| 302 |
+
import google.generativeai as genai
|
| 303 |
+
|
| 304 |
+
if not base_url:
|
| 305 |
+
genai.configure(api_key=api_key, transport="rest")
|
| 306 |
+
else:
|
| 307 |
+
genai.configure(api_key=api_key, transport="rest", client_options={'api_endpoint': base_url})
|
| 308 |
+
|
| 309 |
+
generation_config = {
|
| 310 |
+
"temperature": 0.5,
|
| 311 |
+
"top_p": 1,
|
| 312 |
+
"top_k": 1,
|
| 313 |
+
"max_output_tokens": 2048,
|
| 314 |
+
}
|
| 315 |
+
|
| 316 |
+
safety_settings = [
|
| 317 |
+
{
|
| 318 |
+
"category": "HARM_CATEGORY_HARASSMENT",
|
| 319 |
+
"threshold": "BLOCK_ONLY_HIGH",
|
| 320 |
+
},
|
| 321 |
+
{
|
| 322 |
+
"category": "HARM_CATEGORY_HATE_SPEECH",
|
| 323 |
+
"threshold": "BLOCK_ONLY_HIGH",
|
| 324 |
+
},
|
| 325 |
+
{
|
| 326 |
+
"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT",
|
| 327 |
+
"threshold": "BLOCK_ONLY_HIGH",
|
| 328 |
+
},
|
| 329 |
+
{
|
| 330 |
+
"category": "HARM_CATEGORY_DANGEROUS_CONTENT",
|
| 331 |
+
"threshold": "BLOCK_ONLY_HIGH",
|
| 332 |
+
},
|
| 333 |
+
]
|
| 334 |
+
|
| 335 |
+
model = genai.GenerativeModel(
|
| 336 |
+
model_name=model_name,
|
| 337 |
+
generation_config=generation_config,
|
| 338 |
+
safety_settings=safety_settings,
|
| 339 |
+
)
|
| 340 |
+
|
| 341 |
+
try:
|
| 342 |
+
response = model.generate_content(prompt)
|
| 343 |
+
candidates = response.candidates
|
| 344 |
+
generated_text = candidates[0].content.parts[0].text
|
| 345 |
+
except (AttributeError, IndexError) as e:
|
| 346 |
+
logger.warning(
|
| 347 |
+
f"gemini returned invalid response content: {str(e)}"
|
| 348 |
+
)
|
| 349 |
+
raise ValueError(
|
| 350 |
+
f"[{llm_provider}] returned invalid response content"
|
| 351 |
+
)
|
| 352 |
+
|
| 353 |
+
return _normalize_text_response(generated_text, llm_provider)
|
| 354 |
+
|
| 355 |
+
if llm_provider == "cloudflare":
|
| 356 |
+
response = requests.post(
|
| 357 |
+
f"https://api.cloudflare.com/client/v4/accounts/{account_id}/ai/run/{model_name}",
|
| 358 |
+
headers={"Authorization": f"Bearer {api_key}"},
|
| 359 |
+
json={
|
| 360 |
+
"messages": [
|
| 361 |
+
{
|
| 362 |
+
"role": "system",
|
| 363 |
+
"content": "You are a friendly assistant",
|
| 364 |
+
},
|
| 365 |
+
{"role": "user", "content": prompt},
|
| 366 |
+
]
|
| 367 |
+
},
|
| 368 |
+
)
|
| 369 |
+
result = response.json()
|
| 370 |
+
logger.info(result)
|
| 371 |
+
return _normalize_text_response(result["result"]["response"], llm_provider)
|
| 372 |
+
|
| 373 |
+
if llm_provider == "ernie":
|
| 374 |
+
response = requests.post(
|
| 375 |
+
"https://aip.baidubce.com/oauth/2.0/token",
|
| 376 |
+
params={
|
| 377 |
+
"grant_type": "client_credentials",
|
| 378 |
+
"client_id": api_key,
|
| 379 |
+
"client_secret": secret_key,
|
| 380 |
+
}
|
| 381 |
+
)
|
| 382 |
+
access_token = response.json().get("access_token")
|
| 383 |
+
url = f"{base_url}?access_token={access_token}"
|
| 384 |
+
|
| 385 |
+
payload = json.dumps(
|
| 386 |
+
{
|
| 387 |
+
"messages": [{"role": "user", "content": prompt}],
|
| 388 |
+
"temperature": 0.5,
|
| 389 |
+
"top_p": 0.8,
|
| 390 |
+
"penalty_score": 1,
|
| 391 |
+
"disable_search": False,
|
| 392 |
+
"enable_citation": False,
|
| 393 |
+
"response_format": "text",
|
| 394 |
+
}
|
| 395 |
+
)
|
| 396 |
+
headers = {"Content-Type": "application/json"}
|
| 397 |
+
|
| 398 |
+
response = requests.request(
|
| 399 |
+
"POST", url, headers=headers, data=payload
|
| 400 |
+
).json()
|
| 401 |
+
return _normalize_text_response(response.get("result"), llm_provider)
|
| 402 |
+
|
| 403 |
+
if llm_provider == "litellm":
|
| 404 |
+
import litellm
|
| 405 |
+
|
| 406 |
+
if not model_name:
|
| 407 |
+
raise ValueError(
|
| 408 |
+
f"{llm_provider}: model_name is not set, please set it in the config.toml file."
|
| 409 |
+
)
|
| 410 |
+
|
| 411 |
+
response = litellm.completion(
|
| 412 |
+
model=model_name,
|
| 413 |
+
messages=[{"role": "user", "content": prompt}],
|
| 414 |
+
drop_params=True,
|
| 415 |
+
)
|
| 416 |
+
|
| 417 |
+
if not response:
|
| 418 |
+
raise ValueError(f"[{llm_provider}] returned empty response")
|
| 419 |
+
if not getattr(response, "choices", None):
|
| 420 |
+
raise ValueError(f"[{llm_provider}] returned empty response")
|
| 421 |
+
|
| 422 |
+
return _extract_chat_completion_text(response, llm_provider)
|
| 423 |
+
|
| 424 |
+
if llm_provider == "azure":
|
| 425 |
+
# Azure OpenAI SDK 使用 `azure_endpoint` 和 `api_version` 生成专用请求地址,
|
| 426 |
+
# 不能继续复用下面普通 OpenAI-compatible 的 `base_url` 初始化逻辑。
|
| 427 |
+
# 这里在 Azure 分支内完成请求并立即返回,避免客户端被后续 fallback
|
| 428 |
+
# 覆盖,导致用户配���的 Azure 凭证通过校验但实际请求没有被使用。
|
| 429 |
+
logger.info(f"requesting azure chat completion, model: {model_name}")
|
| 430 |
+
client = AzureOpenAI(
|
| 431 |
+
api_key=api_key,
|
| 432 |
+
api_version=api_version,
|
| 433 |
+
azure_endpoint=base_url,
|
| 434 |
+
)
|
| 435 |
+
response = client.chat.completions.create(
|
| 436 |
+
model=model_name, messages=[{"role": "user", "content": prompt}]
|
| 437 |
+
)
|
| 438 |
+
if response:
|
| 439 |
+
if isinstance(response, ChatCompletion):
|
| 440 |
+
return _extract_chat_completion_text(response, llm_provider)
|
| 441 |
+
else:
|
| 442 |
+
raise Exception(
|
| 443 |
+
f'[{llm_provider}] returned an invalid response: "{response}", please check your network '
|
| 444 |
+
f"connection and try again."
|
| 445 |
+
)
|
| 446 |
+
else:
|
| 447 |
+
raise Exception(
|
| 448 |
+
f"[{llm_provider}] returned an empty response, please check your network connection and try again."
|
| 449 |
+
)
|
| 450 |
+
|
| 451 |
+
if llm_provider == "modelscope":
|
| 452 |
+
content = ''
|
| 453 |
+
client = OpenAI(
|
| 454 |
+
api_key=api_key,
|
| 455 |
+
base_url=base_url,
|
| 456 |
+
)
|
| 457 |
+
response = client.chat.completions.create(
|
| 458 |
+
model=model_name,
|
| 459 |
+
messages=[{"role": "user", "content": prompt}],
|
| 460 |
+
extra_body={"enable_thinking": False},
|
| 461 |
+
stream=True
|
| 462 |
+
)
|
| 463 |
+
if response:
|
| 464 |
+
for chunk in response:
|
| 465 |
+
if not chunk.choices:
|
| 466 |
+
continue
|
| 467 |
+
delta = chunk.choices[0].delta
|
| 468 |
+
if delta and delta.content:
|
| 469 |
+
content += delta.content
|
| 470 |
+
|
| 471 |
+
if not content.strip():
|
| 472 |
+
raise ValueError("Empty content in stream response")
|
| 473 |
+
|
| 474 |
+
return _normalize_text_response(content, llm_provider)
|
| 475 |
+
else:
|
| 476 |
+
raise Exception(f"[{llm_provider}] returned an empty response")
|
| 477 |
+
|
| 478 |
+
else:
|
| 479 |
+
client = OpenAI(
|
| 480 |
+
api_key=api_key,
|
| 481 |
+
base_url=base_url,
|
| 482 |
+
)
|
| 483 |
+
|
| 484 |
+
response = client.chat.completions.create(
|
| 485 |
+
model=model_name, messages=[{"role": "user", "content": prompt}]
|
| 486 |
+
)
|
| 487 |
+
if response:
|
| 488 |
+
if isinstance(response, ChatCompletion):
|
| 489 |
+
return _extract_chat_completion_text(response, llm_provider)
|
| 490 |
+
else:
|
| 491 |
+
raise Exception(
|
| 492 |
+
f'[{llm_provider}] returned an invalid response: "{response}", please check your network '
|
| 493 |
+
f"connection and try again."
|
| 494 |
+
)
|
| 495 |
+
else:
|
| 496 |
+
raise Exception(
|
| 497 |
+
f"[{llm_provider}] returned an empty response, please check your network connection and try again."
|
| 498 |
+
)
|
| 499 |
+
|
| 500 |
+
return _normalize_text_response(content, llm_provider)
|
| 501 |
+
except Exception as e:
|
| 502 |
+
return f"Error: {str(e)}"
|
| 503 |
+
|
| 504 |
+
|
| 505 |
+
def _limit_script_text(text: str | None, max_length: int, field_name: str) -> str:
|
| 506 |
+
value = (text or "").strip()
|
| 507 |
+
if len(value) <= max_length:
|
| 508 |
+
return value
|
| 509 |
+
|
| 510 |
+
# API 层已经用 Pydantic 做长度校验;这里继续兜底,是为了保护
|
| 511 |
+
# WebUI 或内部服务直接调用 generate_script 时不会把超长提示词发送给模型,
|
| 512 |
+
# 避免 token 成本异常和请求失败。
|
| 513 |
+
logger.warning(
|
| 514 |
+
f"{field_name} is too long and will be truncated to {max_length} characters."
|
| 515 |
+
)
|
| 516 |
+
return value[:max_length]
|
| 517 |
+
|
| 518 |
+
|
| 519 |
+
def _normalize_script_paragraph_number(paragraph_number: int | None) -> int:
|
| 520 |
+
try:
|
| 521 |
+
value = int(paragraph_number or MIN_SCRIPT_PARAGRAPH_NUMBER)
|
| 522 |
+
except (TypeError, ValueError):
|
| 523 |
+
value = MIN_SCRIPT_PARAGRAPH_NUMBER
|
| 524 |
+
|
| 525 |
+
if value < MIN_SCRIPT_PARAGRAPH_NUMBER or value > MAX_SCRIPT_PARAGRAPH_NUMBER:
|
| 526 |
+
# WebUI 和 API 都会限制范围;这里兜底处理内部调用,避免异常参数直接扩大
|
| 527 |
+
# LLM 生成成本或生成空结果。
|
| 528 |
+
logger.warning(
|
| 529 |
+
"script paragraph_number is out of range and will be clamped: "
|
| 530 |
+
f"{value}"
|
| 531 |
+
)
|
| 532 |
+
return max(MIN_SCRIPT_PARAGRAPH_NUMBER, min(value, MAX_SCRIPT_PARAGRAPH_NUMBER))
|
| 533 |
+
|
| 534 |
+
return value
|
| 535 |
+
|
| 536 |
+
|
| 537 |
+
def build_script_prompt(
|
| 538 |
+
video_subject: str,
|
| 539 |
+
language: str = "",
|
| 540 |
+
paragraph_number: int = 1,
|
| 541 |
+
video_script_prompt: str = "",
|
| 542 |
+
custom_system_prompt: str = "",
|
| 543 |
+
) -> str:
|
| 544 |
+
paragraph_number = _normalize_script_paragraph_number(paragraph_number)
|
| 545 |
+
video_script_prompt = _limit_script_text(
|
| 546 |
+
video_script_prompt, MAX_SCRIPT_PROMPT_LENGTH, "video_script_prompt"
|
| 547 |
+
)
|
| 548 |
+
custom_system_prompt = _limit_script_text(
|
| 549 |
+
custom_system_prompt, MAX_SCRIPT_SYSTEM_PROMPT_LENGTH, "custom_system_prompt"
|
| 550 |
+
)
|
| 551 |
+
|
| 552 |
+
# 将“脚本生成规则”和“运行时上下文”分开拼接。这样高级用户即使覆盖默认
|
| 553 |
+
# system prompt,也不会漏掉视频主题、语言、段落数这些每次生成都必须带上的参数。
|
| 554 |
+
prompt = custom_system_prompt or DEFAULT_SCRIPT_SYSTEM_PROMPT
|
| 555 |
+
prompt += f"""
|
| 556 |
+
|
| 557 |
+
# Initialization:
|
| 558 |
+
- video subject: {video_subject}
|
| 559 |
+
- number of paragraphs: {paragraph_number}
|
| 560 |
+
""".rstrip()
|
| 561 |
+
if language:
|
| 562 |
+
prompt += f"\n- language: {language}"
|
| 563 |
+
if video_script_prompt:
|
| 564 |
+
prompt += f"""
|
| 565 |
+
|
| 566 |
+
# Additional User Requirements:
|
| 567 |
+
{video_script_prompt}
|
| 568 |
+
""".rstrip()
|
| 569 |
+
|
| 570 |
+
return prompt
|
| 571 |
+
|
| 572 |
+
|
| 573 |
+
def generate_script(
|
| 574 |
+
video_subject: str,
|
| 575 |
+
language: str = "",
|
| 576 |
+
paragraph_number: int = 1,
|
| 577 |
+
video_script_prompt: str = "",
|
| 578 |
+
custom_system_prompt: str = "",
|
| 579 |
+
) -> str:
|
| 580 |
+
paragraph_number = _normalize_script_paragraph_number(paragraph_number)
|
| 581 |
+
video_script_prompt = _limit_script_text(
|
| 582 |
+
video_script_prompt, MAX_SCRIPT_PROMPT_LENGTH, "video_script_prompt"
|
| 583 |
+
)
|
| 584 |
+
custom_system_prompt = _limit_script_text(
|
| 585 |
+
custom_system_prompt, MAX_SCRIPT_SYSTEM_PROMPT_LENGTH, "custom_system_prompt"
|
| 586 |
+
)
|
| 587 |
+
prompt = build_script_prompt(
|
| 588 |
+
video_subject=video_subject,
|
| 589 |
+
language=language,
|
| 590 |
+
paragraph_number=paragraph_number,
|
| 591 |
+
video_script_prompt=video_script_prompt,
|
| 592 |
+
custom_system_prompt=custom_system_prompt,
|
| 593 |
+
)
|
| 594 |
+
final_script = ""
|
| 595 |
+
logger.info(
|
| 596 |
+
"generating video script: "
|
| 597 |
+
f"subject={video_subject}, paragraph_number={paragraph_number}, "
|
| 598 |
+
f"has_custom_prompt={bool(video_script_prompt.strip())}, "
|
| 599 |
+
f"has_custom_system_prompt={bool(custom_system_prompt.strip())}"
|
| 600 |
+
)
|
| 601 |
+
|
| 602 |
+
def format_response(response):
|
| 603 |
+
# Clean the script
|
| 604 |
+
# Remove asterisks, hashes
|
| 605 |
+
response = response.replace("*", "")
|
| 606 |
+
response = response.replace("#", "")
|
| 607 |
+
|
| 608 |
+
# Remove markdown syntax
|
| 609 |
+
response = re.sub(r"\[.*\]", "", response)
|
| 610 |
+
response = re.sub(r"\(.*\)", "", response)
|
| 611 |
+
|
| 612 |
+
# Split the script into paragraphs
|
| 613 |
+
paragraphs = response.split("\n\n")
|
| 614 |
+
|
| 615 |
+
# Select the specified number of paragraphs
|
| 616 |
+
# selected_paragraphs = paragraphs[:paragraph_number]
|
| 617 |
+
|
| 618 |
+
# Join the selected paragraphs into a single string
|
| 619 |
+
return "\n\n".join(paragraphs)
|
| 620 |
+
|
| 621 |
+
for i in range(_max_retries):
|
| 622 |
+
try:
|
| 623 |
+
response = _generate_response(prompt=prompt)
|
| 624 |
+
if response:
|
| 625 |
+
final_script = format_response(response)
|
| 626 |
+
else:
|
| 627 |
+
logging.error("gpt returned an empty response")
|
| 628 |
+
|
| 629 |
+
# g4f may return an error message
|
| 630 |
+
if final_script and "当日额度已消耗完" in final_script:
|
| 631 |
+
raise ValueError(final_script)
|
| 632 |
+
|
| 633 |
+
if final_script:
|
| 634 |
+
break
|
| 635 |
+
except Exception as e:
|
| 636 |
+
logger.error(f"failed to generate script: {e}")
|
| 637 |
+
|
| 638 |
+
if i < _max_retries:
|
| 639 |
+
logger.warning(f"failed to generate video script, trying again... {i + 1}")
|
| 640 |
+
if "Error: " in final_script:
|
| 641 |
+
logger.error(f"failed to generate video script: {final_script}")
|
| 642 |
+
else:
|
| 643 |
+
logger.success(f"completed: \n{final_script}")
|
| 644 |
+
return final_script.strip()
|
| 645 |
+
|
| 646 |
+
|
| 647 |
+
def generate_terms(video_subject: str, video_script: str, amount: int = 5) -> List[str]:
|
| 648 |
+
prompt = f"""
|
| 649 |
+
# Role: Video Search Terms Generator
|
| 650 |
+
|
| 651 |
+
## Goals:
|
| 652 |
+
Generate {amount} search terms for stock videos, depending on the subject of a video.
|
| 653 |
+
|
| 654 |
+
## Constrains:
|
| 655 |
+
1. the search terms are to be returned as a json-array of strings.
|
| 656 |
+
2. each search term should consist of 1-3 words, always add the main subject of the video.
|
| 657 |
+
3. you must only return the json-array of strings. you must not return anything else. you must not return the script.
|
| 658 |
+
4. the search terms must be related to the subject of the video.
|
| 659 |
+
5. reply with english search terms only.
|
| 660 |
+
|
| 661 |
+
## Output Example:
|
| 662 |
+
["search term 1", "search term 2", "search term 3","search term 4","search term 5"]
|
| 663 |
+
|
| 664 |
+
## Context:
|
| 665 |
+
### Video Subject
|
| 666 |
+
{video_subject}
|
| 667 |
+
|
| 668 |
+
### Video Script
|
| 669 |
+
{video_script}
|
| 670 |
+
|
| 671 |
+
Please note that you must use English for generating video search terms; Chinese is not accepted.
|
| 672 |
+
""".strip()
|
| 673 |
+
|
| 674 |
+
logger.info(f"subject: {video_subject}")
|
| 675 |
+
|
| 676 |
+
search_terms = []
|
| 677 |
+
response = ""
|
| 678 |
+
for i in range(_max_retries):
|
| 679 |
+
try:
|
| 680 |
+
response = _generate_response(prompt)
|
| 681 |
+
if "Error: " in response:
|
| 682 |
+
logger.error(f"failed to generate video script: {response}")
|
| 683 |
+
return response
|
| 684 |
+
search_terms = json.loads(response)
|
| 685 |
+
if not isinstance(search_terms, list) or not all(
|
| 686 |
+
isinstance(term, str) for term in search_terms
|
| 687 |
+
):
|
| 688 |
+
logger.error("response is not a list of strings.")
|
| 689 |
+
continue
|
| 690 |
+
|
| 691 |
+
except Exception as e:
|
| 692 |
+
logger.warning(f"failed to generate video terms: {str(e)}")
|
| 693 |
+
if response:
|
| 694 |
+
match = re.search(r"\[.*]", response)
|
| 695 |
+
if match:
|
| 696 |
+
try:
|
| 697 |
+
search_terms = json.loads(match.group())
|
| 698 |
+
except Exception as e:
|
| 699 |
+
# 这里保留重试流程,但必须记录 LLM 返回的非标准 JSON,
|
| 700 |
+
# 否则后续排查搜索词为空时无法定位
|
| 701 |
+
# 是模型格式问题还是解析逻辑问题。
|
| 702 |
+
logger.warning(f"failed to generate video terms: {str(e)}")
|
| 703 |
+
|
| 704 |
+
if search_terms and len(search_terms) > 0:
|
| 705 |
+
break
|
| 706 |
+
if i < _max_retries:
|
| 707 |
+
logger.warning(f"failed to generate video terms, trying again... {i + 1}")
|
| 708 |
+
|
| 709 |
+
logger.success(f"completed: \n{search_terms}")
|
| 710 |
+
return search_terms
|
| 711 |
+
|
| 712 |
+
|
| 713 |
+
if __name__ == "__main__":
|
| 714 |
+
video_subject = "生命的意义是什么"
|
| 715 |
+
script = generate_script(
|
| 716 |
+
video_subject=video_subject, language="zh-CN", paragraph_number=1
|
| 717 |
+
)
|
| 718 |
+
print("######################")
|
| 719 |
+
print(script)
|
| 720 |
+
search_terms = generate_terms(
|
| 721 |
+
video_subject=video_subject, video_script=script, amount=5
|
| 722 |
+
)
|
| 723 |
+
print("######################")
|
| 724 |
+
print(search_terms)
|
| 725 |
+
|
app/services/material.py
ADDED
|
@@ -0,0 +1,388 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import random
|
| 3 |
+
import threading
|
| 4 |
+
from typing import List
|
| 5 |
+
from urllib.parse import urlencode
|
| 6 |
+
|
| 7 |
+
import requests
|
| 8 |
+
from loguru import logger
|
| 9 |
+
from moviepy.video.io.VideoFileClip import VideoFileClip
|
| 10 |
+
|
| 11 |
+
from app.config import config
|
| 12 |
+
from app.models.schema import MaterialInfo, VideoAspect, VideoConcatMode
|
| 13 |
+
from app.utils import utils
|
| 14 |
+
|
| 15 |
+
# Thread-safe counter for API key rotation
|
| 16 |
+
_api_key_counter = 0
|
| 17 |
+
_api_key_lock = threading.Lock()
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def _get_tls_verify() -> bool:
|
| 21 |
+
# 默认开启 TLS 证书校验,防止素材搜索和下载过程被中间人篡改。
|
| 22 |
+
# 仅在企业代理、自签证书等明确需要的场景下,允许用户通过
|
| 23 |
+
# `config.toml` 显式设置 `tls_verify = false` 临时关闭。
|
| 24 |
+
tls_verify = config.app.get("tls_verify", True)
|
| 25 |
+
if isinstance(tls_verify, str):
|
| 26 |
+
tls_verify = tls_verify.strip().lower() not in ("0", "false", "no", "off")
|
| 27 |
+
|
| 28 |
+
if not tls_verify:
|
| 29 |
+
logger.warning(
|
| 30 |
+
"TLS certificate verification is disabled by config.app.tls_verify=false. "
|
| 31 |
+
"Only use this in trusted proxy environments."
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
+
return bool(tls_verify)
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def get_api_key(cfg_key: str):
|
| 38 |
+
api_keys = config.app.get(cfg_key)
|
| 39 |
+
if not api_keys:
|
| 40 |
+
raise ValueError(
|
| 41 |
+
f"\n\n##### {cfg_key} is not set #####\n\nPlease set it in the config.toml file: {config.config_file}\n\n"
|
| 42 |
+
f"{utils.to_json(config.app)}"
|
| 43 |
+
)
|
| 44 |
+
|
| 45 |
+
# if only one key is provided, return it
|
| 46 |
+
if isinstance(api_keys, str):
|
| 47 |
+
return api_keys
|
| 48 |
+
|
| 49 |
+
global _api_key_counter
|
| 50 |
+
with _api_key_lock:
|
| 51 |
+
_api_key_counter += 1
|
| 52 |
+
return api_keys[_api_key_counter % len(api_keys)]
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def search_videos_pexels(
|
| 56 |
+
search_term: str,
|
| 57 |
+
minimum_duration: int,
|
| 58 |
+
video_aspect: VideoAspect = VideoAspect.portrait,
|
| 59 |
+
) -> List[MaterialInfo]:
|
| 60 |
+
aspect = VideoAspect(video_aspect)
|
| 61 |
+
video_orientation = aspect.name
|
| 62 |
+
video_width, video_height = aspect.to_resolution()
|
| 63 |
+
api_key = get_api_key("pexels_api_keys")
|
| 64 |
+
headers = {
|
| 65 |
+
"Authorization": api_key,
|
| 66 |
+
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115.0.0.0 Safari/537.36",
|
| 67 |
+
}
|
| 68 |
+
# Build URL
|
| 69 |
+
params = {"query": search_term, "per_page": 20, "orientation": video_orientation}
|
| 70 |
+
query_url = f"https://api.pexels.com/videos/search?{urlencode(params)}"
|
| 71 |
+
logger.info(f"searching videos: {query_url}, with proxies: {config.proxy}")
|
| 72 |
+
|
| 73 |
+
try:
|
| 74 |
+
r = requests.get(
|
| 75 |
+
query_url,
|
| 76 |
+
headers=headers,
|
| 77 |
+
proxies=config.proxy,
|
| 78 |
+
verify=_get_tls_verify(),
|
| 79 |
+
timeout=(30, 60),
|
| 80 |
+
)
|
| 81 |
+
response = r.json()
|
| 82 |
+
video_items = []
|
| 83 |
+
if "videos" not in response:
|
| 84 |
+
logger.error(f"search videos failed: {response}")
|
| 85 |
+
return video_items
|
| 86 |
+
videos = response["videos"]
|
| 87 |
+
# loop through each video in the result
|
| 88 |
+
for v in videos:
|
| 89 |
+
duration = v["duration"]
|
| 90 |
+
# check if video has desired minimum duration
|
| 91 |
+
if duration < minimum_duration:
|
| 92 |
+
continue
|
| 93 |
+
video_files = v["video_files"]
|
| 94 |
+
# loop through each url to determine the best quality
|
| 95 |
+
for video in video_files:
|
| 96 |
+
w = int(video["width"])
|
| 97 |
+
h = int(video["height"])
|
| 98 |
+
if w == video_width and h == video_height:
|
| 99 |
+
item = MaterialInfo()
|
| 100 |
+
item.provider = "pexels"
|
| 101 |
+
item.url = video["link"]
|
| 102 |
+
item.duration = duration
|
| 103 |
+
video_items.append(item)
|
| 104 |
+
break
|
| 105 |
+
return video_items
|
| 106 |
+
except Exception as e:
|
| 107 |
+
logger.error(f"search videos failed: {str(e)}")
|
| 108 |
+
|
| 109 |
+
return []
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
def search_videos_pixabay(
|
| 113 |
+
search_term: str,
|
| 114 |
+
minimum_duration: int,
|
| 115 |
+
video_aspect: VideoAspect = VideoAspect.portrait,
|
| 116 |
+
) -> List[MaterialInfo]:
|
| 117 |
+
aspect = VideoAspect(video_aspect)
|
| 118 |
+
|
| 119 |
+
video_width, video_height = aspect.to_resolution()
|
| 120 |
+
|
| 121 |
+
api_key = get_api_key("pixabay_api_keys")
|
| 122 |
+
# Build URL
|
| 123 |
+
params = {
|
| 124 |
+
"q": search_term,
|
| 125 |
+
"video_type": "all", # Accepted values: "all", "film", "animation"
|
| 126 |
+
"per_page": 50,
|
| 127 |
+
"key": api_key,
|
| 128 |
+
}
|
| 129 |
+
query_url = f"https://pixabay.com/api/videos/?{urlencode(params)}"
|
| 130 |
+
logger.info(f"searching videos: {query_url}, with proxies: {config.proxy}")
|
| 131 |
+
|
| 132 |
+
try:
|
| 133 |
+
r = requests.get(
|
| 134 |
+
query_url, proxies=config.proxy, verify=_get_tls_verify(), timeout=(30, 60)
|
| 135 |
+
)
|
| 136 |
+
response = r.json()
|
| 137 |
+
video_items = []
|
| 138 |
+
if "hits" not in response:
|
| 139 |
+
logger.error(f"search videos failed: {response}")
|
| 140 |
+
return video_items
|
| 141 |
+
videos = response["hits"]
|
| 142 |
+
# loop through each video in the result
|
| 143 |
+
for v in videos:
|
| 144 |
+
duration = v["duration"]
|
| 145 |
+
# check if video has desired minimum duration
|
| 146 |
+
if duration < minimum_duration:
|
| 147 |
+
continue
|
| 148 |
+
video_files = v["videos"]
|
| 149 |
+
# loop through each url to determine the best quality
|
| 150 |
+
for video_type in video_files:
|
| 151 |
+
video = video_files[video_type]
|
| 152 |
+
w = int(video["width"])
|
| 153 |
+
# h = int(video["height"])
|
| 154 |
+
if w >= video_width:
|
| 155 |
+
item = MaterialInfo()
|
| 156 |
+
item.provider = "pixabay"
|
| 157 |
+
item.url = video["url"]
|
| 158 |
+
item.duration = duration
|
| 159 |
+
video_items.append(item)
|
| 160 |
+
break
|
| 161 |
+
return video_items
|
| 162 |
+
except Exception as e:
|
| 163 |
+
logger.error(f"search videos failed: {str(e)}")
|
| 164 |
+
|
| 165 |
+
return []
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
def search_images_nvidia(
|
| 169 |
+
search_term: str,
|
| 170 |
+
minimum_duration: int,
|
| 171 |
+
video_aspect: VideoAspect = VideoAspect.portrait,
|
| 172 |
+
) -> List[MaterialInfo]:
|
| 173 |
+
api_key = config.app.get("nvidia_api_key")
|
| 174 |
+
if not api_key:
|
| 175 |
+
logger.error("nvidia_api_key is empty")
|
| 176 |
+
return []
|
| 177 |
+
|
| 178 |
+
url = "https://ai.api.nvidia.com/v1/genai/black-forest-labs/flux.1-dev"
|
| 179 |
+
headers = {
|
| 180 |
+
"Authorization": f"Bearer {api_key}",
|
| 181 |
+
"Content-Type": "application/json",
|
| 182 |
+
"Accept": "application/json"
|
| 183 |
+
}
|
| 184 |
+
|
| 185 |
+
# stabilityai/stable-diffusion-xl and flux.1-dev support specific resolutions:
|
| 186 |
+
# 1024x1024, 1152x896, 1216x832, 1344x768, 1536x640, 896x1152, 832x1216, 768x1344, 640x1536
|
| 187 |
+
# Let's use 896x1152 for portrait (approx 9:16) and 1152x896 for landscape (approx 16:9)
|
| 188 |
+
aspect = VideoAspect(video_aspect)
|
| 189 |
+
if aspect == VideoAspect.portrait:
|
| 190 |
+
width, height = 896, 1152
|
| 191 |
+
else:
|
| 192 |
+
width, height = 1152, 896
|
| 193 |
+
|
| 194 |
+
payload = {
|
| 195 |
+
"prompt": search_term,
|
| 196 |
+
"cfg_scale": 5,
|
| 197 |
+
"mode": "base",
|
| 198 |
+
"steps": 25,
|
| 199 |
+
"seed": 0,
|
| 200 |
+
"samples": 1,
|
| 201 |
+
"width": width,
|
| 202 |
+
"height": height
|
| 203 |
+
}
|
| 204 |
+
|
| 205 |
+
logger.info(f"Generating image via Nvidia API for: {search_term} ({width}x{height})")
|
| 206 |
+
try:
|
| 207 |
+
r = requests.post(url, json=payload, headers=headers, timeout=120)
|
| 208 |
+
logger.info(f"Nvidia API Response Status Code: {r.status_code}")
|
| 209 |
+
if r.status_code != 200:
|
| 210 |
+
logger.error(f"Nvidia API error: {r.text}")
|
| 211 |
+
return []
|
| 212 |
+
|
| 213 |
+
response = r.json()
|
| 214 |
+
if "artifacts" not in response or len(response["artifacts"]) == 0:
|
| 215 |
+
logger.error(f"Nvidia API failed: {response}")
|
| 216 |
+
return []
|
| 217 |
+
|
| 218 |
+
b64 = response["artifacts"][0]["base64"]
|
| 219 |
+
|
| 220 |
+
import base64
|
| 221 |
+
import uuid
|
| 222 |
+
from moviepy import ImageClip
|
| 223 |
+
|
| 224 |
+
save_dir = utils.storage_dir("cache_videos")
|
| 225 |
+
if not os.path.exists(save_dir):
|
| 226 |
+
os.makedirs(save_dir)
|
| 227 |
+
|
| 228 |
+
image_id = str(uuid.uuid4())
|
| 229 |
+
image_path = f"{save_dir}/{image_id}.jpg"
|
| 230 |
+
video_path = f"{save_dir}/{image_id}.mp4"
|
| 231 |
+
|
| 232 |
+
with open(image_path, "wb") as f:
|
| 233 |
+
f.write(base64.b64decode(b64))
|
| 234 |
+
|
| 235 |
+
# Convert image to video clip
|
| 236 |
+
clip = ImageClip(image_path).with_duration(minimum_duration)
|
| 237 |
+
clip.write_videofile(video_path, fps=30, codec="libx264", logger=None)
|
| 238 |
+
clip.close()
|
| 239 |
+
|
| 240 |
+
item = MaterialInfo()
|
| 241 |
+
item.provider = "nvidia"
|
| 242 |
+
item.url = video_path
|
| 243 |
+
item.duration = minimum_duration
|
| 244 |
+
return [item]
|
| 245 |
+
|
| 246 |
+
except Exception as e:
|
| 247 |
+
logger.error(f"Nvidia Image generation failed: {str(e)}")
|
| 248 |
+
|
| 249 |
+
return []
|
| 250 |
+
|
| 251 |
+
|
| 252 |
+
def save_video(video_url: str, save_dir: str = "") -> str:
|
| 253 |
+
if not str(video_url).startswith("http"):
|
| 254 |
+
return video_url
|
| 255 |
+
|
| 256 |
+
if not save_dir:
|
| 257 |
+
save_dir = utils.storage_dir("cache_videos")
|
| 258 |
+
|
| 259 |
+
if not os.path.exists(save_dir):
|
| 260 |
+
os.makedirs(save_dir)
|
| 261 |
+
|
| 262 |
+
url_without_query = video_url.split("?")[0]
|
| 263 |
+
url_hash = utils.md5(url_without_query)
|
| 264 |
+
video_id = f"vid-{url_hash}"
|
| 265 |
+
video_path = f"{save_dir}/{video_id}.mp4"
|
| 266 |
+
|
| 267 |
+
# if video already exists, return the path
|
| 268 |
+
if os.path.exists(video_path) and os.path.getsize(video_path) > 0:
|
| 269 |
+
logger.info(f"video already exists: {video_path}")
|
| 270 |
+
return video_path
|
| 271 |
+
|
| 272 |
+
headers = {
|
| 273 |
+
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115.0.0.0 Safari/537.36"
|
| 274 |
+
}
|
| 275 |
+
|
| 276 |
+
# if video does not exist, download it
|
| 277 |
+
with open(video_path, "wb") as f:
|
| 278 |
+
f.write(
|
| 279 |
+
requests.get(
|
| 280 |
+
video_url,
|
| 281 |
+
headers=headers,
|
| 282 |
+
proxies=config.proxy,
|
| 283 |
+
verify=_get_tls_verify(),
|
| 284 |
+
timeout=(60, 240),
|
| 285 |
+
).content
|
| 286 |
+
)
|
| 287 |
+
|
| 288 |
+
if os.path.exists(video_path) and os.path.getsize(video_path) > 0:
|
| 289 |
+
clip = None
|
| 290 |
+
try:
|
| 291 |
+
clip = VideoFileClip(video_path)
|
| 292 |
+
duration = clip.duration
|
| 293 |
+
fps = clip.fps
|
| 294 |
+
if duration > 0 and fps > 0:
|
| 295 |
+
return video_path
|
| 296 |
+
except Exception as e:
|
| 297 |
+
logger.warning(f"invalid video file: {video_path} => {str(e)}")
|
| 298 |
+
try:
|
| 299 |
+
os.remove(video_path)
|
| 300 |
+
except Exception as remove_error:
|
| 301 |
+
logger.warning(
|
| 302 |
+
f"failed to remove invalid video file: {video_path}, error: {str(remove_error)}"
|
| 303 |
+
)
|
| 304 |
+
finally:
|
| 305 |
+
if clip is not None:
|
| 306 |
+
try:
|
| 307 |
+
clip.close()
|
| 308 |
+
except Exception as close_error:
|
| 309 |
+
logger.warning(
|
| 310 |
+
f"failed to close video clip: {video_path}, error: {str(close_error)}"
|
| 311 |
+
)
|
| 312 |
+
return ""
|
| 313 |
+
|
| 314 |
+
|
| 315 |
+
def download_videos(
|
| 316 |
+
task_id: str,
|
| 317 |
+
search_terms: List[str],
|
| 318 |
+
source: str = "pexels",
|
| 319 |
+
video_aspect: VideoAspect = VideoAspect.portrait,
|
| 320 |
+
video_contact_mode: VideoConcatMode = VideoConcatMode.random,
|
| 321 |
+
audio_duration: float = 0.0,
|
| 322 |
+
max_clip_duration: int = 5,
|
| 323 |
+
) -> List[str]:
|
| 324 |
+
valid_video_items = []
|
| 325 |
+
valid_video_urls = []
|
| 326 |
+
found_duration = 0.0
|
| 327 |
+
search_videos = search_videos_pexels
|
| 328 |
+
if source == "pixabay":
|
| 329 |
+
search_videos = search_videos_pixabay
|
| 330 |
+
elif source == "nvidia":
|
| 331 |
+
search_videos = search_images_nvidia
|
| 332 |
+
|
| 333 |
+
for search_term in search_terms:
|
| 334 |
+
video_items = search_videos(
|
| 335 |
+
search_term=search_term,
|
| 336 |
+
minimum_duration=max_clip_duration,
|
| 337 |
+
video_aspect=video_aspect,
|
| 338 |
+
)
|
| 339 |
+
logger.info(f"found {len(video_items)} videos for '{search_term}'")
|
| 340 |
+
|
| 341 |
+
for item in video_items:
|
| 342 |
+
if item.url not in valid_video_urls:
|
| 343 |
+
valid_video_items.append(item)
|
| 344 |
+
valid_video_urls.append(item.url)
|
| 345 |
+
found_duration += item.duration
|
| 346 |
+
|
| 347 |
+
logger.info(
|
| 348 |
+
f"found total videos: {len(valid_video_items)}, required duration: {audio_duration} seconds, found duration: {found_duration} seconds"
|
| 349 |
+
)
|
| 350 |
+
video_paths = []
|
| 351 |
+
|
| 352 |
+
material_directory = config.app.get("material_directory", "").strip()
|
| 353 |
+
if material_directory == "task":
|
| 354 |
+
material_directory = utils.task_dir(task_id)
|
| 355 |
+
elif material_directory and not os.path.isdir(material_directory):
|
| 356 |
+
material_directory = ""
|
| 357 |
+
|
| 358 |
+
concat_mode_value = getattr(video_contact_mode, "value", video_contact_mode)
|
| 359 |
+
if concat_mode_value == VideoConcatMode.random.value:
|
| 360 |
+
random.shuffle(valid_video_items)
|
| 361 |
+
|
| 362 |
+
total_duration = 0.0
|
| 363 |
+
for item in valid_video_items:
|
| 364 |
+
try:
|
| 365 |
+
logger.info(f"downloading video: {item.url}")
|
| 366 |
+
saved_video_path = save_video(
|
| 367 |
+
video_url=item.url, save_dir=material_directory
|
| 368 |
+
)
|
| 369 |
+
if saved_video_path:
|
| 370 |
+
logger.info(f"video saved: {saved_video_path}")
|
| 371 |
+
video_paths.append(saved_video_path)
|
| 372 |
+
seconds = min(max_clip_duration, item.duration)
|
| 373 |
+
total_duration += seconds
|
| 374 |
+
if total_duration > audio_duration:
|
| 375 |
+
logger.info(
|
| 376 |
+
f"total duration of downloaded videos: {total_duration} seconds, skip downloading more"
|
| 377 |
+
)
|
| 378 |
+
break
|
| 379 |
+
except Exception as e:
|
| 380 |
+
logger.error(f"failed to download video: {utils.to_json(item)} => {str(e)}")
|
| 381 |
+
logger.success(f"downloaded {len(video_paths)} videos")
|
| 382 |
+
return video_paths
|
| 383 |
+
|
| 384 |
+
|
| 385 |
+
if __name__ == "__main__":
|
| 386 |
+
download_videos(
|
| 387 |
+
"test123", ["Money Exchange Medium"], audio_duration=100, source="pixabay"
|
| 388 |
+
)
|
app/services/state.py
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import ast
|
| 2 |
+
from abc import ABC, abstractmethod
|
| 3 |
+
|
| 4 |
+
from app.config import config
|
| 5 |
+
from app.models import const
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
# Base class for state management
|
| 9 |
+
class BaseState(ABC):
|
| 10 |
+
@abstractmethod
|
| 11 |
+
def update_task(self, task_id: str, state: int, progress: int = 0, **kwargs):
|
| 12 |
+
pass
|
| 13 |
+
|
| 14 |
+
@abstractmethod
|
| 15 |
+
def get_task(self, task_id: str):
|
| 16 |
+
pass
|
| 17 |
+
|
| 18 |
+
@abstractmethod
|
| 19 |
+
def get_all_tasks(self, page: int, page_size: int):
|
| 20 |
+
pass
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
# Memory state management
|
| 24 |
+
class MemoryState(BaseState):
|
| 25 |
+
def __init__(self):
|
| 26 |
+
self._tasks = {}
|
| 27 |
+
|
| 28 |
+
def get_all_tasks(self, page: int, page_size: int):
|
| 29 |
+
start = (page - 1) * page_size
|
| 30 |
+
end = start + page_size
|
| 31 |
+
tasks = list(self._tasks.values())
|
| 32 |
+
total = len(tasks)
|
| 33 |
+
return tasks[start:end], total
|
| 34 |
+
|
| 35 |
+
def update_task(
|
| 36 |
+
self,
|
| 37 |
+
task_id: str,
|
| 38 |
+
state: int = const.TASK_STATE_PROCESSING,
|
| 39 |
+
progress: int = 0,
|
| 40 |
+
**kwargs,
|
| 41 |
+
):
|
| 42 |
+
progress = int(progress)
|
| 43 |
+
if progress > 100:
|
| 44 |
+
progress = 100
|
| 45 |
+
|
| 46 |
+
self._tasks[task_id] = {
|
| 47 |
+
"task_id": task_id,
|
| 48 |
+
"state": state,
|
| 49 |
+
"progress": progress,
|
| 50 |
+
**kwargs,
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
def get_task(self, task_id: str):
|
| 54 |
+
return self._tasks.get(task_id, None)
|
| 55 |
+
|
| 56 |
+
def delete_task(self, task_id: str):
|
| 57 |
+
if task_id in self._tasks:
|
| 58 |
+
del self._tasks[task_id]
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
# Redis state management
|
| 62 |
+
class RedisState(BaseState):
|
| 63 |
+
def __init__(self, host="localhost", port=6379, db=0, password=None):
|
| 64 |
+
import redis
|
| 65 |
+
|
| 66 |
+
self._redis = redis.StrictRedis(host=host, port=port, db=db, password=password)
|
| 67 |
+
|
| 68 |
+
def get_all_tasks(self, page: int, page_size: int):
|
| 69 |
+
start = (page - 1) * page_size
|
| 70 |
+
end = start + page_size
|
| 71 |
+
tasks = []
|
| 72 |
+
cursor = 0
|
| 73 |
+
total = 0
|
| 74 |
+
while True:
|
| 75 |
+
cursor, keys = self._redis.scan(cursor, count=page_size)
|
| 76 |
+
batch_start = total
|
| 77 |
+
batch_size = len(keys)
|
| 78 |
+
total += batch_size
|
| 79 |
+
|
| 80 |
+
# Redis SCAN 是分批返回 key。分页切片必须基于“当前批次起始索引”
|
| 81 |
+
# 计算,而不能用累积后的 total 反推,否则第一页会切到空数组,
|
| 82 |
+
# 第二页也可能只返回部分数据。
|
| 83 |
+
if batch_start < end and total > start:
|
| 84 |
+
slice_start = max(0, start - batch_start)
|
| 85 |
+
slice_end = min(batch_size, end - batch_start)
|
| 86 |
+
for key in keys[slice_start:slice_end]:
|
| 87 |
+
task_data = self._redis.hgetall(key)
|
| 88 |
+
task = {
|
| 89 |
+
k.decode("utf-8"): self._convert_to_original_type(v)
|
| 90 |
+
for k, v in task_data.items()
|
| 91 |
+
}
|
| 92 |
+
tasks.append(task)
|
| 93 |
+
|
| 94 |
+
# 即使当前页已经取满,也要继续 SCAN 到 cursor=0,
|
| 95 |
+
# 因为调用方需要准确 total 来渲染分页信息。
|
| 96 |
+
if cursor == 0:
|
| 97 |
+
break
|
| 98 |
+
return tasks, total
|
| 99 |
+
|
| 100 |
+
def update_task(
|
| 101 |
+
self,
|
| 102 |
+
task_id: str,
|
| 103 |
+
state: int = const.TASK_STATE_PROCESSING,
|
| 104 |
+
progress: int = 0,
|
| 105 |
+
**kwargs,
|
| 106 |
+
):
|
| 107 |
+
progress = int(progress)
|
| 108 |
+
if progress > 100:
|
| 109 |
+
progress = 100
|
| 110 |
+
|
| 111 |
+
fields = {
|
| 112 |
+
"task_id": task_id,
|
| 113 |
+
"state": state,
|
| 114 |
+
"progress": progress,
|
| 115 |
+
**kwargs,
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
for field, value in fields.items():
|
| 119 |
+
self._redis.hset(task_id, field, str(value))
|
| 120 |
+
|
| 121 |
+
def get_task(self, task_id: str):
|
| 122 |
+
task_data = self._redis.hgetall(task_id)
|
| 123 |
+
if not task_data:
|
| 124 |
+
return None
|
| 125 |
+
|
| 126 |
+
task = {
|
| 127 |
+
key.decode("utf-8"): self._convert_to_original_type(value)
|
| 128 |
+
for key, value in task_data.items()
|
| 129 |
+
}
|
| 130 |
+
return task
|
| 131 |
+
|
| 132 |
+
def delete_task(self, task_id: str):
|
| 133 |
+
self._redis.delete(task_id)
|
| 134 |
+
|
| 135 |
+
@staticmethod
|
| 136 |
+
def _convert_to_original_type(value):
|
| 137 |
+
"""
|
| 138 |
+
Convert the value from byte string to its original data type.
|
| 139 |
+
You can extend this method to handle other data types as needed.
|
| 140 |
+
"""
|
| 141 |
+
value_str = value.decode("utf-8")
|
| 142 |
+
|
| 143 |
+
try:
|
| 144 |
+
# try to convert byte string array to list
|
| 145 |
+
return ast.literal_eval(value_str)
|
| 146 |
+
except (ValueError, SyntaxError):
|
| 147 |
+
pass
|
| 148 |
+
|
| 149 |
+
if value_str.isdigit():
|
| 150 |
+
return int(value_str)
|
| 151 |
+
# Add more conversions here if needed
|
| 152 |
+
return value_str
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
# Global state
|
| 156 |
+
_enable_redis = config.app.get("enable_redis", False)
|
| 157 |
+
_redis_host = config.app.get("redis_host", "localhost")
|
| 158 |
+
_redis_port = config.app.get("redis_port", 6379)
|
| 159 |
+
_redis_db = config.app.get("redis_db", 0)
|
| 160 |
+
_redis_password = config.app.get("redis_password", None)
|
| 161 |
+
|
| 162 |
+
state = (
|
| 163 |
+
RedisState(
|
| 164 |
+
host=_redis_host, port=_redis_port, db=_redis_db, password=_redis_password
|
| 165 |
+
)
|
| 166 |
+
if _enable_redis
|
| 167 |
+
else MemoryState()
|
| 168 |
+
)
|
app/services/subtitle.py
ADDED
|
@@ -0,0 +1,306 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
import os.path
|
| 3 |
+
import re
|
| 4 |
+
from timeit import default_timer as timer
|
| 5 |
+
|
| 6 |
+
try:
|
| 7 |
+
from faster_whisper import WhisperModel
|
| 8 |
+
except ImportError:
|
| 9 |
+
WhisperModel = None
|
| 10 |
+
from loguru import logger
|
| 11 |
+
|
| 12 |
+
from app.config import config
|
| 13 |
+
from app.utils import utils
|
| 14 |
+
|
| 15 |
+
model_size = config.whisper.get("model_size", "large-v3")
|
| 16 |
+
device = config.whisper.get("device", "cpu")
|
| 17 |
+
compute_type = config.whisper.get("compute_type", "int8")
|
| 18 |
+
model = None
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def create(audio_file, subtitle_file: str = ""):
|
| 22 |
+
global model
|
| 23 |
+
if WhisperModel is None:
|
| 24 |
+
logger.warning("faster_whisper not available, skipping whisper subtitle generation")
|
| 25 |
+
return ""
|
| 26 |
+
if not model:
|
| 27 |
+
model_path = f"{utils.root_dir()}/models/whisper-{model_size}"
|
| 28 |
+
model_bin_file = f"{model_path}/model.bin"
|
| 29 |
+
if not os.path.isdir(model_path) or not os.path.isfile(model_bin_file):
|
| 30 |
+
model_path = model_size
|
| 31 |
+
|
| 32 |
+
logger.info(
|
| 33 |
+
f"loading model: {model_path}, device: {device}, compute_type: {compute_type}"
|
| 34 |
+
)
|
| 35 |
+
try:
|
| 36 |
+
model = WhisperModel(
|
| 37 |
+
model_size_or_path=model_path, device=device, compute_type=compute_type
|
| 38 |
+
)
|
| 39 |
+
except Exception as e:
|
| 40 |
+
logger.error(
|
| 41 |
+
f"failed to load model: {e} \n\n"
|
| 42 |
+
f"********************************************\n"
|
| 43 |
+
f"this may be caused by network issue. \n"
|
| 44 |
+
f"please download the model manually and put it in the 'models' folder. \n"
|
| 45 |
+
f"see [README.md FAQ](https://github.com/harry0703/MoneyPrinterTurbo) for more details.\n"
|
| 46 |
+
f"********************************************\n\n"
|
| 47 |
+
)
|
| 48 |
+
return None
|
| 49 |
+
|
| 50 |
+
logger.info(f"start, output file: {subtitle_file}")
|
| 51 |
+
if not subtitle_file:
|
| 52 |
+
subtitle_file = f"{audio_file}.srt"
|
| 53 |
+
|
| 54 |
+
segments, info = model.transcribe(
|
| 55 |
+
audio_file,
|
| 56 |
+
beam_size=5,
|
| 57 |
+
word_timestamps=True,
|
| 58 |
+
vad_filter=True,
|
| 59 |
+
vad_parameters=dict(min_silence_duration_ms=500),
|
| 60 |
+
)
|
| 61 |
+
|
| 62 |
+
logger.info(
|
| 63 |
+
f"detected language: '{info.language}', probability: {info.language_probability:.2f}"
|
| 64 |
+
)
|
| 65 |
+
|
| 66 |
+
start = timer()
|
| 67 |
+
subtitles = []
|
| 68 |
+
|
| 69 |
+
def recognized(seg_text, seg_start, seg_end):
|
| 70 |
+
seg_text = seg_text.strip()
|
| 71 |
+
if not seg_text:
|
| 72 |
+
return
|
| 73 |
+
|
| 74 |
+
msg = "[%.2fs -> %.2fs] %s" % (seg_start, seg_end, seg_text)
|
| 75 |
+
logger.debug(msg)
|
| 76 |
+
|
| 77 |
+
subtitles.append(
|
| 78 |
+
{"msg": seg_text, "start_time": seg_start, "end_time": seg_end}
|
| 79 |
+
)
|
| 80 |
+
|
| 81 |
+
for segment in segments:
|
| 82 |
+
words_idx = 0
|
| 83 |
+
words_len = len(segment.words)
|
| 84 |
+
|
| 85 |
+
seg_start = 0
|
| 86 |
+
seg_end = 0
|
| 87 |
+
seg_text = ""
|
| 88 |
+
|
| 89 |
+
if segment.words:
|
| 90 |
+
is_segmented = False
|
| 91 |
+
for word in segment.words:
|
| 92 |
+
if not is_segmented:
|
| 93 |
+
seg_start = word.start
|
| 94 |
+
is_segmented = True
|
| 95 |
+
|
| 96 |
+
seg_end = word.end
|
| 97 |
+
# If it contains punctuation, then break the sentence.
|
| 98 |
+
seg_text += word.word
|
| 99 |
+
|
| 100 |
+
if utils.str_contains_punctuation(word.word):
|
| 101 |
+
# remove last char
|
| 102 |
+
seg_text = seg_text[:-1]
|
| 103 |
+
if not seg_text:
|
| 104 |
+
continue
|
| 105 |
+
|
| 106 |
+
recognized(seg_text, seg_start, seg_end)
|
| 107 |
+
|
| 108 |
+
is_segmented = False
|
| 109 |
+
seg_text = ""
|
| 110 |
+
|
| 111 |
+
if words_idx == 0 and segment.start < word.start:
|
| 112 |
+
seg_start = word.start
|
| 113 |
+
if words_idx == (words_len - 1) and segment.end > word.end:
|
| 114 |
+
seg_end = word.end
|
| 115 |
+
words_idx += 1
|
| 116 |
+
|
| 117 |
+
if not seg_text:
|
| 118 |
+
continue
|
| 119 |
+
|
| 120 |
+
recognized(seg_text, seg_start, seg_end)
|
| 121 |
+
|
| 122 |
+
end = timer()
|
| 123 |
+
|
| 124 |
+
diff = end - start
|
| 125 |
+
logger.info(f"complete, elapsed: {diff:.2f} s")
|
| 126 |
+
|
| 127 |
+
idx = 1
|
| 128 |
+
lines = []
|
| 129 |
+
for subtitle in subtitles:
|
| 130 |
+
text = subtitle.get("msg")
|
| 131 |
+
if text:
|
| 132 |
+
lines.append(
|
| 133 |
+
utils.text_to_srt(
|
| 134 |
+
idx, text, subtitle.get("start_time"), subtitle.get("end_time")
|
| 135 |
+
)
|
| 136 |
+
)
|
| 137 |
+
idx += 1
|
| 138 |
+
|
| 139 |
+
sub = "\n".join(lines) + "\n"
|
| 140 |
+
with open(subtitle_file, "w", encoding="utf-8") as f:
|
| 141 |
+
f.write(sub)
|
| 142 |
+
logger.info(f"subtitle file created: {subtitle_file}")
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
def file_to_subtitles(filename):
|
| 146 |
+
if not filename or not os.path.isfile(filename):
|
| 147 |
+
return []
|
| 148 |
+
|
| 149 |
+
times_texts = []
|
| 150 |
+
current_times = None
|
| 151 |
+
current_text = ""
|
| 152 |
+
index = 0
|
| 153 |
+
with open(filename, "r", encoding="utf-8") as f:
|
| 154 |
+
for line in f:
|
| 155 |
+
times = re.findall("([0-9]*:[0-9]*:[0-9]*,[0-9]*)", line)
|
| 156 |
+
if times:
|
| 157 |
+
current_times = line
|
| 158 |
+
elif line.strip() == "" and current_times:
|
| 159 |
+
index += 1
|
| 160 |
+
times_texts.append((index, current_times.strip(), current_text.strip()))
|
| 161 |
+
current_times, current_text = None, ""
|
| 162 |
+
elif current_times:
|
| 163 |
+
current_text += line
|
| 164 |
+
return times_texts
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
def levenshtein_distance(s1, s2):
|
| 168 |
+
if len(s1) < len(s2):
|
| 169 |
+
return levenshtein_distance(s2, s1)
|
| 170 |
+
|
| 171 |
+
if len(s2) == 0:
|
| 172 |
+
return len(s1)
|
| 173 |
+
|
| 174 |
+
previous_row = range(len(s2) + 1)
|
| 175 |
+
for i, c1 in enumerate(s1):
|
| 176 |
+
current_row = [i + 1]
|
| 177 |
+
for j, c2 in enumerate(s2):
|
| 178 |
+
insertions = previous_row[j + 1] + 1
|
| 179 |
+
deletions = current_row[j] + 1
|
| 180 |
+
substitutions = previous_row[j] + (c1 != c2)
|
| 181 |
+
current_row.append(min(insertions, deletions, substitutions))
|
| 182 |
+
previous_row = current_row
|
| 183 |
+
|
| 184 |
+
return previous_row[-1]
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
def similarity(a, b):
|
| 188 |
+
distance = levenshtein_distance(a.lower(), b.lower())
|
| 189 |
+
max_length = max(len(a), len(b))
|
| 190 |
+
return 1 - (distance / max_length)
|
| 191 |
+
|
| 192 |
+
|
| 193 |
+
def correct(subtitle_file, video_script):
|
| 194 |
+
subtitle_items = file_to_subtitles(subtitle_file)
|
| 195 |
+
normalized_script = utils.normalize_script_for_subtitle_matching(video_script)
|
| 196 |
+
script_lines = utils.split_string_by_punctuations(normalized_script)
|
| 197 |
+
|
| 198 |
+
corrected = False
|
| 199 |
+
new_subtitle_items = []
|
| 200 |
+
script_index = 0
|
| 201 |
+
subtitle_index = 0
|
| 202 |
+
|
| 203 |
+
while script_index < len(script_lines) and subtitle_index < len(subtitle_items):
|
| 204 |
+
script_line = script_lines[script_index].strip()
|
| 205 |
+
subtitle_line = subtitle_items[subtitle_index][2].strip()
|
| 206 |
+
|
| 207 |
+
if script_line == subtitle_line:
|
| 208 |
+
new_subtitle_items.append(subtitle_items[subtitle_index])
|
| 209 |
+
script_index += 1
|
| 210 |
+
subtitle_index += 1
|
| 211 |
+
else:
|
| 212 |
+
combined_subtitle = subtitle_line
|
| 213 |
+
start_time = subtitle_items[subtitle_index][1].split(" --> ")[0]
|
| 214 |
+
end_time = subtitle_items[subtitle_index][1].split(" --> ")[1]
|
| 215 |
+
next_subtitle_index = subtitle_index + 1
|
| 216 |
+
|
| 217 |
+
while next_subtitle_index < len(subtitle_items):
|
| 218 |
+
next_subtitle = subtitle_items[next_subtitle_index][2].strip()
|
| 219 |
+
if similarity(
|
| 220 |
+
script_line, combined_subtitle + " " + next_subtitle
|
| 221 |
+
) > similarity(script_line, combined_subtitle):
|
| 222 |
+
combined_subtitle += " " + next_subtitle
|
| 223 |
+
end_time = subtitle_items[next_subtitle_index][1].split(" --> ")[1]
|
| 224 |
+
next_subtitle_index += 1
|
| 225 |
+
else:
|
| 226 |
+
break
|
| 227 |
+
|
| 228 |
+
if similarity(script_line, combined_subtitle) > 0.8:
|
| 229 |
+
logger.warning(
|
| 230 |
+
f"Merged/Corrected - Script: {script_line}, Subtitle: {combined_subtitle}"
|
| 231 |
+
)
|
| 232 |
+
new_subtitle_items.append(
|
| 233 |
+
(
|
| 234 |
+
len(new_subtitle_items) + 1,
|
| 235 |
+
f"{start_time} --> {end_time}",
|
| 236 |
+
script_line,
|
| 237 |
+
)
|
| 238 |
+
)
|
| 239 |
+
corrected = True
|
| 240 |
+
else:
|
| 241 |
+
logger.warning(
|
| 242 |
+
f"Mismatch - Script: {script_line}, Subtitle: {combined_subtitle}"
|
| 243 |
+
)
|
| 244 |
+
new_subtitle_items.append(
|
| 245 |
+
(
|
| 246 |
+
len(new_subtitle_items) + 1,
|
| 247 |
+
f"{start_time} --> {end_time}",
|
| 248 |
+
script_line,
|
| 249 |
+
)
|
| 250 |
+
)
|
| 251 |
+
corrected = True
|
| 252 |
+
|
| 253 |
+
script_index += 1
|
| 254 |
+
subtitle_index = next_subtitle_index
|
| 255 |
+
|
| 256 |
+
# Process the remaining lines of the script.
|
| 257 |
+
while script_index < len(script_lines):
|
| 258 |
+
logger.warning(f"Extra script line: {script_lines[script_index]}")
|
| 259 |
+
if subtitle_index < len(subtitle_items):
|
| 260 |
+
new_subtitle_items.append(
|
| 261 |
+
(
|
| 262 |
+
len(new_subtitle_items) + 1,
|
| 263 |
+
subtitle_items[subtitle_index][1],
|
| 264 |
+
script_lines[script_index],
|
| 265 |
+
)
|
| 266 |
+
)
|
| 267 |
+
subtitle_index += 1
|
| 268 |
+
else:
|
| 269 |
+
new_subtitle_items.append(
|
| 270 |
+
(
|
| 271 |
+
len(new_subtitle_items) + 1,
|
| 272 |
+
"00:00:00,000 --> 00:00:00,000",
|
| 273 |
+
script_lines[script_index],
|
| 274 |
+
)
|
| 275 |
+
)
|
| 276 |
+
script_index += 1
|
| 277 |
+
corrected = True
|
| 278 |
+
|
| 279 |
+
if corrected:
|
| 280 |
+
with open(subtitle_file, "w", encoding="utf-8") as fd:
|
| 281 |
+
for i, item in enumerate(new_subtitle_items):
|
| 282 |
+
fd.write(f"{i + 1}\n{item[1]}\n{item[2]}\n\n")
|
| 283 |
+
logger.info("Subtitle corrected")
|
| 284 |
+
else:
|
| 285 |
+
logger.success("Subtitle is correct")
|
| 286 |
+
|
| 287 |
+
|
| 288 |
+
if __name__ == "__main__":
|
| 289 |
+
task_id = "c12fd1e6-4b0a-4d65-a075-c87abe35a072"
|
| 290 |
+
task_dir = utils.task_dir(task_id)
|
| 291 |
+
subtitle_file = f"{task_dir}/subtitle.srt"
|
| 292 |
+
audio_file = f"{task_dir}/audio.mp3"
|
| 293 |
+
|
| 294 |
+
subtitles = file_to_subtitles(subtitle_file)
|
| 295 |
+
print(subtitles)
|
| 296 |
+
|
| 297 |
+
script_file = f"{task_dir}/script.json"
|
| 298 |
+
with open(script_file, "r") as f:
|
| 299 |
+
script_content = f.read()
|
| 300 |
+
s = json.loads(script_content)
|
| 301 |
+
script = s.get("script")
|
| 302 |
+
|
| 303 |
+
correct(subtitle_file, script)
|
| 304 |
+
|
| 305 |
+
subtitle_file = f"{task_dir}/subtitle-test.srt"
|
| 306 |
+
create(audio_file, subtitle_file)
|
app/services/task.py
ADDED
|
@@ -0,0 +1,397 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import math
|
| 2 |
+
import os.path
|
| 3 |
+
import re
|
| 4 |
+
from os import path
|
| 5 |
+
|
| 6 |
+
from loguru import logger
|
| 7 |
+
|
| 8 |
+
from app.config import config
|
| 9 |
+
from app.models import const
|
| 10 |
+
from app.models.schema import VideoConcatMode, VideoParams
|
| 11 |
+
from app.services import llm, material, subtitle, video, voice, upload_post
|
| 12 |
+
from app.services import state as sm
|
| 13 |
+
from app.utils import utils
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def generate_script(task_id, params):
|
| 17 |
+
logger.info("\n\n## generating video script")
|
| 18 |
+
video_script = params.video_script.strip()
|
| 19 |
+
if not video_script:
|
| 20 |
+
video_script = llm.generate_script(
|
| 21 |
+
video_subject=params.video_subject,
|
| 22 |
+
language=params.video_language,
|
| 23 |
+
paragraph_number=params.paragraph_number,
|
| 24 |
+
video_script_prompt=params.video_script_prompt,
|
| 25 |
+
custom_system_prompt=params.custom_system_prompt,
|
| 26 |
+
)
|
| 27 |
+
else:
|
| 28 |
+
logger.debug(f"video script: \n{video_script}")
|
| 29 |
+
|
| 30 |
+
if not video_script:
|
| 31 |
+
sm.state.update_task(task_id, state=const.TASK_STATE_FAILED)
|
| 32 |
+
logger.error("failed to generate video script.")
|
| 33 |
+
return None
|
| 34 |
+
|
| 35 |
+
return video_script
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def generate_terms(task_id, params, video_script):
|
| 39 |
+
logger.info("\n\n## generating video terms")
|
| 40 |
+
video_terms = params.video_terms
|
| 41 |
+
if not video_terms:
|
| 42 |
+
video_terms = llm.generate_terms(
|
| 43 |
+
video_subject=params.video_subject, video_script=video_script, amount=5
|
| 44 |
+
)
|
| 45 |
+
else:
|
| 46 |
+
if isinstance(video_terms, str):
|
| 47 |
+
video_terms = [term.strip() for term in re.split(r"[,,]", video_terms)]
|
| 48 |
+
elif isinstance(video_terms, list):
|
| 49 |
+
video_terms = [term.strip() for term in video_terms]
|
| 50 |
+
else:
|
| 51 |
+
raise ValueError("video_terms must be a string or a list of strings.")
|
| 52 |
+
|
| 53 |
+
logger.debug(f"video terms: {utils.to_json(video_terms)}")
|
| 54 |
+
|
| 55 |
+
if not video_terms:
|
| 56 |
+
sm.state.update_task(task_id, state=const.TASK_STATE_FAILED)
|
| 57 |
+
logger.error("failed to generate video terms.")
|
| 58 |
+
return None
|
| 59 |
+
|
| 60 |
+
return video_terms
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def save_script_data(task_id, video_script, video_terms, params):
|
| 64 |
+
script_file = path.join(utils.task_dir(task_id), "script.json")
|
| 65 |
+
script_data = {
|
| 66 |
+
"script": video_script,
|
| 67 |
+
"search_terms": video_terms,
|
| 68 |
+
"params": params,
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
with open(script_file, "w", encoding="utf-8") as f:
|
| 72 |
+
f.write(utils.to_json(script_data))
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def generate_audio(task_id, params, video_script):
|
| 76 |
+
'''
|
| 77 |
+
Generate audio for the video script.
|
| 78 |
+
If a custom audio file is provided, it will be used directly.
|
| 79 |
+
There will be no subtitle maker object returned in this case.
|
| 80 |
+
Otherwise, TTS will be used to generate the audio.
|
| 81 |
+
Returns:
|
| 82 |
+
- audio_file: path to the generated or provided audio file
|
| 83 |
+
- audio_duration: duration of the audio in seconds
|
| 84 |
+
- sub_maker: subtitle maker object if TTS is used, None otherwise
|
| 85 |
+
'''
|
| 86 |
+
logger.info("\n\n## generating audio")
|
| 87 |
+
# /audio 和 /subtitle 请求模型不包含 custom_audio_file,
|
| 88 |
+
# 这里统一做兼容读取,避免直调接口时抛属性错误。
|
| 89 |
+
custom_audio_file = getattr(params, "custom_audio_file", None)
|
| 90 |
+
if not custom_audio_file or not os.path.exists(custom_audio_file):
|
| 91 |
+
if custom_audio_file:
|
| 92 |
+
logger.warning(
|
| 93 |
+
f"custom audio file not found: {custom_audio_file}, using TTS to generate audio."
|
| 94 |
+
)
|
| 95 |
+
else:
|
| 96 |
+
logger.info("no custom audio file provided, using TTS to generate audio.")
|
| 97 |
+
audio_file = path.join(utils.task_dir(task_id), "audio.mp3")
|
| 98 |
+
sub_maker = voice.tts(
|
| 99 |
+
text=video_script,
|
| 100 |
+
voice_name=voice.parse_voice_name(params.voice_name),
|
| 101 |
+
voice_rate=params.voice_rate,
|
| 102 |
+
voice_file=audio_file,
|
| 103 |
+
)
|
| 104 |
+
if sub_maker is None:
|
| 105 |
+
sm.state.update_task(task_id, state=const.TASK_STATE_FAILED)
|
| 106 |
+
logger.error(
|
| 107 |
+
"""failed to generate audio:
|
| 108 |
+
1. check if the language of the voice matches the language of the video script.
|
| 109 |
+
2. check if the network is available. If you are in China, it is recommended to use a VPN and enable the global traffic mode.
|
| 110 |
+
""".strip()
|
| 111 |
+
)
|
| 112 |
+
return None, None, None
|
| 113 |
+
audio_duration = math.ceil(voice.get_audio_duration(sub_maker))
|
| 114 |
+
if audio_duration == 0:
|
| 115 |
+
sm.state.update_task(task_id, state=const.TASK_STATE_FAILED)
|
| 116 |
+
logger.error("failed to get audio duration.")
|
| 117 |
+
return None, None, None
|
| 118 |
+
return audio_file, audio_duration, sub_maker
|
| 119 |
+
else:
|
| 120 |
+
logger.info(f"using custom audio file: {custom_audio_file}")
|
| 121 |
+
audio_duration = voice.get_audio_duration(custom_audio_file)
|
| 122 |
+
if audio_duration == 0:
|
| 123 |
+
sm.state.update_task(task_id, state=const.TASK_STATE_FAILED)
|
| 124 |
+
logger.error("failed to get audio duration from custom audio file.")
|
| 125 |
+
return None, None, None
|
| 126 |
+
return custom_audio_file, audio_duration, None
|
| 127 |
+
|
| 128 |
+
def generate_subtitle(task_id, params, video_script, sub_maker, audio_file):
|
| 129 |
+
'''
|
| 130 |
+
Generate subtitle for the video script.
|
| 131 |
+
If subtitle generation is disabled or no subtitle maker is provided, it will return an empty string.
|
| 132 |
+
Otherwise, it will generate the subtitle using the specified provider.
|
| 133 |
+
Returns:
|
| 134 |
+
- subtitle_path: path to the generated subtitle file
|
| 135 |
+
'''
|
| 136 |
+
logger.info("\n\n## generating subtitle")
|
| 137 |
+
if not params.subtitle_enabled or sub_maker is None:
|
| 138 |
+
return ""
|
| 139 |
+
|
| 140 |
+
subtitle_path = path.join(utils.task_dir(task_id), "subtitle.srt")
|
| 141 |
+
subtitle_provider = config.app.get("subtitle_provider", "edge").strip().lower()
|
| 142 |
+
logger.info(f"\n\n## generating subtitle, provider: {subtitle_provider}")
|
| 143 |
+
|
| 144 |
+
subtitle_fallback = False
|
| 145 |
+
if subtitle_provider == "edge":
|
| 146 |
+
voice.create_subtitle(
|
| 147 |
+
text=video_script, sub_maker=sub_maker, subtitle_file=subtitle_path
|
| 148 |
+
)
|
| 149 |
+
if not os.path.exists(subtitle_path):
|
| 150 |
+
subtitle_fallback = True
|
| 151 |
+
logger.warning("subtitle file not found, fallback to whisper")
|
| 152 |
+
|
| 153 |
+
if subtitle_provider == "whisper" or subtitle_fallback:
|
| 154 |
+
subtitle.create(audio_file=audio_file, subtitle_file=subtitle_path)
|
| 155 |
+
logger.info("\n\n## correcting subtitle")
|
| 156 |
+
subtitle.correct(subtitle_file=subtitle_path, video_script=video_script)
|
| 157 |
+
|
| 158 |
+
subtitle_lines = subtitle.file_to_subtitles(subtitle_path)
|
| 159 |
+
if not subtitle_lines:
|
| 160 |
+
logger.warning(f"subtitle file is invalid: {subtitle_path}")
|
| 161 |
+
return ""
|
| 162 |
+
|
| 163 |
+
return subtitle_path
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
def get_video_materials(task_id, params, video_terms, audio_duration):
|
| 167 |
+
if params.video_source == "local":
|
| 168 |
+
logger.info("\n\n## preprocess local materials")
|
| 169 |
+
materials = video.preprocess_video(
|
| 170 |
+
materials=params.video_materials, clip_duration=params.video_clip_duration
|
| 171 |
+
)
|
| 172 |
+
if not materials:
|
| 173 |
+
sm.state.update_task(task_id, state=const.TASK_STATE_FAILED)
|
| 174 |
+
logger.error(
|
| 175 |
+
"no valid materials found, please check the materials and try again."
|
| 176 |
+
)
|
| 177 |
+
return None
|
| 178 |
+
return [material_info.url for material_info in materials]
|
| 179 |
+
else:
|
| 180 |
+
logger.info(f"\n\n## downloading videos from {params.video_source}")
|
| 181 |
+
downloaded_videos = material.download_videos(
|
| 182 |
+
task_id=task_id,
|
| 183 |
+
search_terms=video_terms,
|
| 184 |
+
source=params.video_source,
|
| 185 |
+
video_aspect=params.video_aspect,
|
| 186 |
+
video_contact_mode=params.video_concat_mode,
|
| 187 |
+
audio_duration=audio_duration * params.video_count,
|
| 188 |
+
max_clip_duration=params.video_clip_duration,
|
| 189 |
+
)
|
| 190 |
+
if not downloaded_videos:
|
| 191 |
+
sm.state.update_task(task_id, state=const.TASK_STATE_FAILED)
|
| 192 |
+
logger.error(
|
| 193 |
+
"failed to download videos, maybe the network is not available. if you are in China, please use a VPN."
|
| 194 |
+
)
|
| 195 |
+
return None
|
| 196 |
+
return downloaded_videos
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
def generate_final_videos(
|
| 200 |
+
task_id, params, downloaded_videos, audio_file, subtitle_path
|
| 201 |
+
):
|
| 202 |
+
final_video_paths = []
|
| 203 |
+
combined_video_paths = []
|
| 204 |
+
video_concat_mode = (
|
| 205 |
+
params.video_concat_mode if params.video_count == 1 else VideoConcatMode.random
|
| 206 |
+
)
|
| 207 |
+
video_transition_mode = params.video_transition_mode
|
| 208 |
+
|
| 209 |
+
_progress = 50
|
| 210 |
+
for i in range(params.video_count):
|
| 211 |
+
index = i + 1
|
| 212 |
+
combined_video_path = path.join(
|
| 213 |
+
utils.task_dir(task_id), f"combined-{index}.mp4"
|
| 214 |
+
)
|
| 215 |
+
logger.info(f"\n\n## combining video: {index} => {combined_video_path}")
|
| 216 |
+
video.combine_videos(
|
| 217 |
+
combined_video_path=combined_video_path,
|
| 218 |
+
video_paths=downloaded_videos,
|
| 219 |
+
audio_file=audio_file,
|
| 220 |
+
video_aspect=params.video_aspect,
|
| 221 |
+
video_concat_mode=video_concat_mode,
|
| 222 |
+
video_transition_mode=video_transition_mode,
|
| 223 |
+
max_clip_duration=params.video_clip_duration,
|
| 224 |
+
threads=params.n_threads,
|
| 225 |
+
)
|
| 226 |
+
|
| 227 |
+
_progress += 50 / params.video_count / 2
|
| 228 |
+
sm.state.update_task(task_id, progress=_progress)
|
| 229 |
+
|
| 230 |
+
final_video_path = path.join(utils.task_dir(task_id), f"final-{index}.mp4")
|
| 231 |
+
|
| 232 |
+
logger.info(f"\n\n## generating video: {index} => {final_video_path}")
|
| 233 |
+
video.generate_video(
|
| 234 |
+
video_path=combined_video_path,
|
| 235 |
+
audio_path=audio_file,
|
| 236 |
+
subtitle_path=subtitle_path,
|
| 237 |
+
output_file=final_video_path,
|
| 238 |
+
params=params,
|
| 239 |
+
)
|
| 240 |
+
|
| 241 |
+
_progress += 50 / params.video_count / 2
|
| 242 |
+
sm.state.update_task(task_id, progress=_progress)
|
| 243 |
+
|
| 244 |
+
final_video_paths.append(final_video_path)
|
| 245 |
+
combined_video_paths.append(combined_video_path)
|
| 246 |
+
|
| 247 |
+
return final_video_paths, combined_video_paths
|
| 248 |
+
|
| 249 |
+
|
| 250 |
+
def start(task_id, params: VideoParams, stop_at: str = "video"):
|
| 251 |
+
logger.info(f"start task: {task_id}, stop_at: {stop_at}")
|
| 252 |
+
sm.state.update_task(task_id, state=const.TASK_STATE_PROCESSING, progress=5)
|
| 253 |
+
|
| 254 |
+
# 1. Generate script
|
| 255 |
+
video_script = generate_script(task_id, params)
|
| 256 |
+
if not video_script or "Error: " in video_script:
|
| 257 |
+
sm.state.update_task(task_id, state=const.TASK_STATE_FAILED)
|
| 258 |
+
return
|
| 259 |
+
|
| 260 |
+
sm.state.update_task(task_id, state=const.TASK_STATE_PROCESSING, progress=10)
|
| 261 |
+
|
| 262 |
+
if stop_at == "script":
|
| 263 |
+
sm.state.update_task(
|
| 264 |
+
task_id, state=const.TASK_STATE_COMPLETE, progress=100, script=video_script
|
| 265 |
+
)
|
| 266 |
+
return {"script": video_script}
|
| 267 |
+
|
| 268 |
+
# 2. Generate terms
|
| 269 |
+
video_terms = ""
|
| 270 |
+
if params.video_source != "local":
|
| 271 |
+
video_terms = generate_terms(task_id, params, video_script)
|
| 272 |
+
if not video_terms:
|
| 273 |
+
sm.state.update_task(task_id, state=const.TASK_STATE_FAILED)
|
| 274 |
+
return
|
| 275 |
+
|
| 276 |
+
save_script_data(task_id, video_script, video_terms, params)
|
| 277 |
+
|
| 278 |
+
if stop_at == "terms":
|
| 279 |
+
sm.state.update_task(
|
| 280 |
+
task_id, state=const.TASK_STATE_COMPLETE, progress=100, terms=video_terms
|
| 281 |
+
)
|
| 282 |
+
return {"script": video_script, "terms": video_terms}
|
| 283 |
+
|
| 284 |
+
sm.state.update_task(task_id, state=const.TASK_STATE_PROCESSING, progress=20)
|
| 285 |
+
|
| 286 |
+
# 3. Generate audio
|
| 287 |
+
audio_file, audio_duration, sub_maker = generate_audio(
|
| 288 |
+
task_id, params, video_script
|
| 289 |
+
)
|
| 290 |
+
if not audio_file:
|
| 291 |
+
sm.state.update_task(task_id, state=const.TASK_STATE_FAILED)
|
| 292 |
+
return
|
| 293 |
+
|
| 294 |
+
sm.state.update_task(task_id, state=const.TASK_STATE_PROCESSING, progress=30)
|
| 295 |
+
|
| 296 |
+
if stop_at == "audio":
|
| 297 |
+
sm.state.update_task(
|
| 298 |
+
task_id,
|
| 299 |
+
state=const.TASK_STATE_COMPLETE,
|
| 300 |
+
progress=100,
|
| 301 |
+
audio_file=audio_file,
|
| 302 |
+
)
|
| 303 |
+
return {"audio_file": audio_file, "audio_duration": audio_duration}
|
| 304 |
+
|
| 305 |
+
# 4. Generate subtitle
|
| 306 |
+
subtitle_path = generate_subtitle(
|
| 307 |
+
task_id, params, video_script, sub_maker, audio_file
|
| 308 |
+
)
|
| 309 |
+
|
| 310 |
+
if stop_at == "subtitle":
|
| 311 |
+
sm.state.update_task(
|
| 312 |
+
task_id,
|
| 313 |
+
state=const.TASK_STATE_COMPLETE,
|
| 314 |
+
progress=100,
|
| 315 |
+
subtitle_path=subtitle_path,
|
| 316 |
+
)
|
| 317 |
+
return {"subtitle_path": subtitle_path}
|
| 318 |
+
|
| 319 |
+
sm.state.update_task(task_id, state=const.TASK_STATE_PROCESSING, progress=40)
|
| 320 |
+
|
| 321 |
+
# 5. Get video materials
|
| 322 |
+
downloaded_videos = get_video_materials(
|
| 323 |
+
task_id, params, video_terms, audio_duration
|
| 324 |
+
)
|
| 325 |
+
if not downloaded_videos:
|
| 326 |
+
sm.state.update_task(task_id, state=const.TASK_STATE_FAILED)
|
| 327 |
+
return
|
| 328 |
+
|
| 329 |
+
if stop_at == "materials":
|
| 330 |
+
sm.state.update_task(
|
| 331 |
+
task_id,
|
| 332 |
+
state=const.TASK_STATE_COMPLETE,
|
| 333 |
+
progress=100,
|
| 334 |
+
materials=downloaded_videos,
|
| 335 |
+
)
|
| 336 |
+
return {"materials": downloaded_videos}
|
| 337 |
+
|
| 338 |
+
sm.state.update_task(task_id, state=const.TASK_STATE_PROCESSING, progress=50)
|
| 339 |
+
|
| 340 |
+
# 仅完整视频生成流程才需要处理视频拼接模式;
|
| 341 |
+
# 这样可以避免 /subtitle 和 /audio 这类请求访问不存在的字段。
|
| 342 |
+
if type(params.video_concat_mode) is str:
|
| 343 |
+
params.video_concat_mode = VideoConcatMode(params.video_concat_mode)
|
| 344 |
+
|
| 345 |
+
# 6. Generate final videos
|
| 346 |
+
final_video_paths, combined_video_paths = generate_final_videos(
|
| 347 |
+
task_id, params, downloaded_videos, audio_file, subtitle_path
|
| 348 |
+
)
|
| 349 |
+
|
| 350 |
+
if not final_video_paths:
|
| 351 |
+
sm.state.update_task(task_id, state=const.TASK_STATE_FAILED)
|
| 352 |
+
return
|
| 353 |
+
|
| 354 |
+
logger.success(
|
| 355 |
+
f"task {task_id} finished, generated {len(final_video_paths)} videos."
|
| 356 |
+
)
|
| 357 |
+
|
| 358 |
+
# 7. Cross-post to TikTok/Instagram (if enabled)
|
| 359 |
+
cross_post_results = []
|
| 360 |
+
if upload_post.upload_post_service.is_configured() and upload_post.upload_post_service.auto_upload:
|
| 361 |
+
logger.info("\n\n## cross-posting videos to TikTok/Instagram")
|
| 362 |
+
for video_path in final_video_paths:
|
| 363 |
+
result = upload_post.cross_post_video(
|
| 364 |
+
video_path=video_path,
|
| 365 |
+
title=params.video_subject or "Check out this video! #shorts #viral"
|
| 366 |
+
)
|
| 367 |
+
cross_post_results.append(result)
|
| 368 |
+
if result.get('success'):
|
| 369 |
+
logger.info(f"✅ Cross-posted: {video_path}")
|
| 370 |
+
else:
|
| 371 |
+
logger.warning(f"⚠️ Failed to cross-post: {video_path} - {result.get('error', 'Unknown error')}")
|
| 372 |
+
|
| 373 |
+
kwargs = {
|
| 374 |
+
"videos": final_video_paths,
|
| 375 |
+
"combined_videos": combined_video_paths,
|
| 376 |
+
"script": video_script,
|
| 377 |
+
"terms": video_terms,
|
| 378 |
+
"audio_file": audio_file,
|
| 379 |
+
"audio_duration": audio_duration,
|
| 380 |
+
"subtitle_path": subtitle_path,
|
| 381 |
+
"materials": downloaded_videos,
|
| 382 |
+
"cross_post_results": cross_post_results if cross_post_results else None,
|
| 383 |
+
}
|
| 384 |
+
sm.state.update_task(
|
| 385 |
+
task_id, state=const.TASK_STATE_COMPLETE, progress=100, **kwargs
|
| 386 |
+
)
|
| 387 |
+
return kwargs
|
| 388 |
+
|
| 389 |
+
|
| 390 |
+
if __name__ == "__main__":
|
| 391 |
+
task_id = "task_id"
|
| 392 |
+
params = VideoParams(
|
| 393 |
+
video_subject="金钱的作用",
|
| 394 |
+
voice_name="zh-CN-XiaoyiNeural-Female",
|
| 395 |
+
voice_rate=1.0,
|
| 396 |
+
)
|
| 397 |
+
start(task_id, params, stop_at="video")
|
app/services/upload_post.py
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Upload-Post API integration for cross-posting videos to TikTok and Instagram.
|
| 3 |
+
|
| 4 |
+
Docs: https://docs.upload-post.com
|
| 5 |
+
"""
|
| 6 |
+
import os
|
| 7 |
+
import requests
|
| 8 |
+
from loguru import logger
|
| 9 |
+
from app.config import config
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class UploadPostService:
|
| 13 |
+
"""
|
| 14 |
+
Service for cross-posting videos to TikTok/Instagram via Upload-Post API.
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
API_BASE = "https://api.upload-post.com"
|
| 18 |
+
|
| 19 |
+
def __init__(self):
|
| 20 |
+
self.api_key = config.app.get("upload_post_api_key", "")
|
| 21 |
+
self.username = config.app.get("upload_post_username", "")
|
| 22 |
+
self.enabled = config.app.get("upload_post_enabled", False)
|
| 23 |
+
self.platforms = config.app.get("upload_post_platforms", ["tiktok", "instagram"])
|
| 24 |
+
self.auto_upload = config.app.get("upload_post_auto_upload", False)
|
| 25 |
+
|
| 26 |
+
def is_configured(self) -> bool:
|
| 27 |
+
"""Check if Upload-Post is properly configured."""
|
| 28 |
+
return bool(self.api_key and self.username and self.enabled)
|
| 29 |
+
|
| 30 |
+
def upload_video(
|
| 31 |
+
self,
|
| 32 |
+
video_path: str,
|
| 33 |
+
title: str,
|
| 34 |
+
platforms: list = None,
|
| 35 |
+
privacy_level: str = "PUBLIC_TO_EVERYONE"
|
| 36 |
+
) -> dict:
|
| 37 |
+
"""
|
| 38 |
+
Upload a video to TikTok and/or Instagram.
|
| 39 |
+
|
| 40 |
+
Args:
|
| 41 |
+
video_path (str): Path to the video file
|
| 42 |
+
title (str): Video title/caption (max 2200 chars for Instagram)
|
| 43 |
+
platforms (list): List of platforms ["tiktok", "instagram"]
|
| 44 |
+
privacy_level (str): Privacy level for the video
|
| 45 |
+
|
| 46 |
+
Returns:
|
| 47 |
+
dict: API response with request_id and status
|
| 48 |
+
"""
|
| 49 |
+
if not self.is_configured():
|
| 50 |
+
logger.warning("Upload-Post is not configured. Skipping cross-post.")
|
| 51 |
+
return {"success": False, "error": "Upload-Post not configured"}
|
| 52 |
+
|
| 53 |
+
if platforms is None:
|
| 54 |
+
platforms = self.platforms
|
| 55 |
+
|
| 56 |
+
if not os.path.exists(video_path):
|
| 57 |
+
logger.error(f"Video file not found: {video_path}")
|
| 58 |
+
return {"success": False, "error": f"Video file not found: {video_path}"}
|
| 59 |
+
|
| 60 |
+
logger.info(f"Cross-posting video to {', '.join(platforms)} via Upload-Post...")
|
| 61 |
+
|
| 62 |
+
try:
|
| 63 |
+
with open(video_path, 'rb') as video_file:
|
| 64 |
+
files = {'video': video_file}
|
| 65 |
+
|
| 66 |
+
data = {
|
| 67 |
+
'user': self.username,
|
| 68 |
+
'title': title[:2200],
|
| 69 |
+
'privacy_level': privacy_level
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
# Add each platform
|
| 73 |
+
for i, platform in enumerate(platforms):
|
| 74 |
+
data[f'platform[{i}]'] = platform
|
| 75 |
+
|
| 76 |
+
headers = {
|
| 77 |
+
'Authorization': f'Apikey {self.api_key}'
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
response = requests.post(
|
| 81 |
+
f"{self.API_BASE}/api/upload_video",
|
| 82 |
+
headers=headers,
|
| 83 |
+
data=data,
|
| 84 |
+
files=files,
|
| 85 |
+
timeout=300
|
| 86 |
+
)
|
| 87 |
+
|
| 88 |
+
response.raise_for_status()
|
| 89 |
+
result = response.json()
|
| 90 |
+
|
| 91 |
+
if result.get('success'):
|
| 92 |
+
logger.info(f"✅ Video cross-posted successfully! Request ID: {result.get('request_id')}")
|
| 93 |
+
else:
|
| 94 |
+
logger.warning(f"Cross-post failed: {result.get('message', 'Unknown error')}")
|
| 95 |
+
|
| 96 |
+
return result
|
| 97 |
+
|
| 98 |
+
except requests.exceptions.RequestException as e:
|
| 99 |
+
logger.error(f"Failed to cross-post video: {str(e)}")
|
| 100 |
+
return {"success": False, "error": str(e)}
|
| 101 |
+
|
| 102 |
+
def check_status(self, request_id: str) -> dict:
|
| 103 |
+
"""
|
| 104 |
+
Check the status of an upload request.
|
| 105 |
+
|
| 106 |
+
Args:
|
| 107 |
+
request_id (str): The request ID from upload
|
| 108 |
+
|
| 109 |
+
Returns:
|
| 110 |
+
dict: Status information
|
| 111 |
+
"""
|
| 112 |
+
try:
|
| 113 |
+
headers = {
|
| 114 |
+
'Authorization': f'Apikey {self.api_key}'
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
response = requests.get(
|
| 118 |
+
f"{self.API_BASE}/api/status/{request_id}",
|
| 119 |
+
headers=headers,
|
| 120 |
+
timeout=30
|
| 121 |
+
)
|
| 122 |
+
|
| 123 |
+
response.raise_for_status()
|
| 124 |
+
return response.json()
|
| 125 |
+
|
| 126 |
+
except requests.exceptions.RequestException as e:
|
| 127 |
+
logger.error(f"Failed to check status: {str(e)}")
|
| 128 |
+
return {"success": False, "error": str(e)}
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
# Singleton instance
|
| 132 |
+
upload_post_service = UploadPostService()
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
def cross_post_video(video_path: str, title: str, platforms: list = None) -> dict:
|
| 136 |
+
"""
|
| 137 |
+
Convenience function to cross-post a video.
|
| 138 |
+
|
| 139 |
+
Args:
|
| 140 |
+
video_path (str): Path to the video file
|
| 141 |
+
title (str): Video title/caption
|
| 142 |
+
platforms (list): List of platforms (defaults to config)
|
| 143 |
+
|
| 144 |
+
Returns:
|
| 145 |
+
dict: API response
|
| 146 |
+
"""
|
| 147 |
+
return upload_post_service.upload_video(video_path, title, platforms)
|
app/services/utils/video_effects.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from moviepy import Clip, ColorClip, CompositeVideoClip, vfx
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
# FadeIn
|
| 5 |
+
def fadein_transition(clip: Clip, t: float) -> Clip:
|
| 6 |
+
return clip.with_effects([vfx.FadeIn(t)])
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
# FadeOut
|
| 10 |
+
def fadeout_transition(clip: Clip, t: float) -> Clip:
|
| 11 |
+
return clip.with_effects([vfx.FadeOut(t)])
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
# SlideIn
|
| 15 |
+
def slidein_transition(clip: Clip, t: float, side: str) -> Clip:
|
| 16 |
+
width, height = clip.size
|
| 17 |
+
|
| 18 |
+
# MoviePy 内置 SlideIn 在当前这条处理链里对全屏素材不稳定,
|
| 19 |
+
# 会出现“逻辑上应用了转场,但画面几乎看不出变化”的情况。
|
| 20 |
+
# 这里改成显式黑底 + 位移动画,保证转场效果可见且行为可控。
|
| 21 |
+
def position(current_time: float):
|
| 22 |
+
progress = min(max(current_time / max(t, 0.001), 0), 1)
|
| 23 |
+
|
| 24 |
+
if side == "left":
|
| 25 |
+
return (-width + width * progress, 0)
|
| 26 |
+
if side == "right":
|
| 27 |
+
return (width - width * progress, 0)
|
| 28 |
+
if side == "top":
|
| 29 |
+
return (0, -height + height * progress)
|
| 30 |
+
if side == "bottom":
|
| 31 |
+
return (0, height - height * progress)
|
| 32 |
+
return (0, 0)
|
| 33 |
+
|
| 34 |
+
background = ColorClip(size=(width, height), color=(0, 0, 0)).with_duration(
|
| 35 |
+
clip.duration
|
| 36 |
+
)
|
| 37 |
+
moving_clip = clip.with_position(position)
|
| 38 |
+
return CompositeVideoClip([background, moving_clip], size=(width, height)).with_duration(
|
| 39 |
+
clip.duration
|
| 40 |
+
)
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
# SlideOut
|
| 44 |
+
def slideout_transition(clip: Clip, t: float, side: str) -> Clip:
|
| 45 |
+
width, height = clip.size
|
| 46 |
+
transition_start = max(clip.duration - t, 0)
|
| 47 |
+
|
| 48 |
+
# SlideOut 同样改成显式位移,保证片段末尾能稳定滑出画面。
|
| 49 |
+
def position(current_time: float):
|
| 50 |
+
if current_time <= transition_start:
|
| 51 |
+
return (0, 0)
|
| 52 |
+
|
| 53 |
+
progress = min(
|
| 54 |
+
max((current_time - transition_start) / max(t, 0.001), 0), 1
|
| 55 |
+
)
|
| 56 |
+
|
| 57 |
+
if side == "left":
|
| 58 |
+
return (-width * progress, 0)
|
| 59 |
+
if side == "right":
|
| 60 |
+
return (width * progress, 0)
|
| 61 |
+
if side == "top":
|
| 62 |
+
return (0, -height * progress)
|
| 63 |
+
if side == "bottom":
|
| 64 |
+
return (0, height * progress)
|
| 65 |
+
return (0, 0)
|
| 66 |
+
|
| 67 |
+
background = ColorClip(size=(width, height), color=(0, 0, 0)).with_duration(
|
| 68 |
+
clip.duration
|
| 69 |
+
)
|
| 70 |
+
moving_clip = clip.with_position(position)
|
| 71 |
+
return CompositeVideoClip([background, moving_clip], size=(width, height)).with_duration(
|
| 72 |
+
clip.duration
|
| 73 |
+
)
|
app/services/video.py
ADDED
|
@@ -0,0 +1,928 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import glob
|
| 2 |
+
import itertools
|
| 3 |
+
import io
|
| 4 |
+
import os
|
| 5 |
+
import random
|
| 6 |
+
import gc
|
| 7 |
+
import shutil
|
| 8 |
+
import subprocess
|
| 9 |
+
from contextlib import redirect_stdout
|
| 10 |
+
from typing import List
|
| 11 |
+
from loguru import logger
|
| 12 |
+
import numpy as np
|
| 13 |
+
from moviepy import (
|
| 14 |
+
AudioFileClip,
|
| 15 |
+
ColorClip,
|
| 16 |
+
CompositeAudioClip,
|
| 17 |
+
CompositeVideoClip,
|
| 18 |
+
ImageClip,
|
| 19 |
+
TextClip,
|
| 20 |
+
VideoFileClip,
|
| 21 |
+
afx,
|
| 22 |
+
)
|
| 23 |
+
from moviepy.video.tools.subtitles import SubtitlesClip
|
| 24 |
+
from PIL import Image, ImageDraw, ImageFont
|
| 25 |
+
|
| 26 |
+
from app.models import const
|
| 27 |
+
from app.models.schema import (
|
| 28 |
+
MaterialInfo,
|
| 29 |
+
VideoAspect,
|
| 30 |
+
VideoConcatMode,
|
| 31 |
+
VideoParams,
|
| 32 |
+
VideoTransitionMode,
|
| 33 |
+
)
|
| 34 |
+
from app.services.utils import video_effects
|
| 35 |
+
from app.utils import file_security, utils
|
| 36 |
+
|
| 37 |
+
class SubClippedVideoClip:
|
| 38 |
+
def __init__(
|
| 39 |
+
self,
|
| 40 |
+
file_path,
|
| 41 |
+
start_time=None,
|
| 42 |
+
end_time=None,
|
| 43 |
+
width=None,
|
| 44 |
+
height=None,
|
| 45 |
+
duration=None,
|
| 46 |
+
source_file_path=None,
|
| 47 |
+
):
|
| 48 |
+
self.file_path = file_path
|
| 49 |
+
self.start_time = start_time
|
| 50 |
+
self.end_time = end_time
|
| 51 |
+
self.width = width
|
| 52 |
+
self.height = height
|
| 53 |
+
self.source_file_path = source_file_path or file_path
|
| 54 |
+
if duration is None:
|
| 55 |
+
self.duration = end_time - start_time
|
| 56 |
+
else:
|
| 57 |
+
self.duration = duration
|
| 58 |
+
|
| 59 |
+
def __str__(self):
|
| 60 |
+
return f"SubClippedVideoClip(file_path={self.file_path}, start_time={self.start_time}, end_time={self.end_time}, duration={self.duration}, width={self.width}, height={self.height})"
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
audio_codec = "aac"
|
| 64 |
+
# Docker 里的 ffmpeg/AAC 组合在默认配置下更容易出现音频质量波动,
|
| 65 |
+
# 这里显式抬高音频码率,避免成片阶段因为默认值过低而引入明显失真。
|
| 66 |
+
audio_bitrate = "192k"
|
| 67 |
+
video_codec = "libx264"
|
| 68 |
+
fps = 30
|
| 69 |
+
_BGM_EXTENSIONS = (".mp3",)
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def _prioritize_unique_source_clips(
|
| 73 |
+
subclipped_items: List[SubClippedVideoClip],
|
| 74 |
+
concat_mode: VideoConcatMode,
|
| 75 |
+
) -> List[SubClippedVideoClip]:
|
| 76 |
+
"""
|
| 77 |
+
优先让每个源素材只出现一次,降低成片里同一素材反复出现的概率。
|
| 78 |
+
|
| 79 |
+
线上素材经常会遇到“一个长视频被切成多个短片段”的情况。旧逻辑在
|
| 80 |
+
random 模式下直接打乱所有短片段,导致同一个源视频的多个切片可能
|
| 81 |
+
分布在开头和中间,用户会感知为素材重复。本函数只调整片段顺序:
|
| 82 |
+
先放每个源文件里最长的一个片段,剩余片段作为兜底;当素材总时长不足时,
|
| 83 |
+
仍然允许后续片段补齐音频长度,避免破坏视频生成成功率。优先选择最长
|
| 84 |
+
片段是为了避免随机选中视频尾部的零碎短片段,导致明明有足够素材却过早复用。
|
| 85 |
+
"""
|
| 86 |
+
if not subclipped_items:
|
| 87 |
+
return []
|
| 88 |
+
|
| 89 |
+
concat_mode_value = getattr(concat_mode, "value", concat_mode)
|
| 90 |
+
if concat_mode_value != VideoConcatMode.random.value:
|
| 91 |
+
return subclipped_items
|
| 92 |
+
|
| 93 |
+
grouped_items: dict[str, list[SubClippedVideoClip]] = {}
|
| 94 |
+
for item in subclipped_items:
|
| 95 |
+
grouped_items.setdefault(item.source_file_path, []).append(item)
|
| 96 |
+
|
| 97 |
+
primary_items = []
|
| 98 |
+
overflow_items = []
|
| 99 |
+
for items in grouped_items.values():
|
| 100 |
+
primary_item = max(items, key=lambda item: item.duration)
|
| 101 |
+
primary_items.append(primary_item)
|
| 102 |
+
overflow_items.extend(item for item in items if item is not primary_item)
|
| 103 |
+
|
| 104 |
+
random.shuffle(primary_items)
|
| 105 |
+
random.shuffle(overflow_items)
|
| 106 |
+
logger.info(
|
| 107 |
+
"prioritized unique video materials, "
|
| 108 |
+
f"sources: {len(grouped_items)}, "
|
| 109 |
+
f"primary clips: {len(primary_items)}, "
|
| 110 |
+
f"fallback clips: {len(overflow_items)}"
|
| 111 |
+
)
|
| 112 |
+
return primary_items + overflow_items
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def get_ffmpeg_binary():
|
| 116 |
+
# 优先复用用户在 config.toml / 环境变量里显式指定的 ffmpeg,可避免
|
| 117 |
+
# Windows 便携包、Docker、自定义安装目录等场景下 PATH 不一致。
|
| 118 |
+
configured_ffmpeg = os.environ.get("IMAGEIO_FFMPEG_EXE")
|
| 119 |
+
if configured_ffmpeg:
|
| 120 |
+
return configured_ffmpeg
|
| 121 |
+
|
| 122 |
+
system_ffmpeg = shutil.which("ffmpeg")
|
| 123 |
+
if system_ffmpeg:
|
| 124 |
+
return system_ffmpeg
|
| 125 |
+
|
| 126 |
+
try:
|
| 127 |
+
import imageio_ffmpeg
|
| 128 |
+
|
| 129 |
+
bundled_ffmpeg = imageio_ffmpeg.get_ffmpeg_exe()
|
| 130 |
+
if bundled_ffmpeg:
|
| 131 |
+
return bundled_ffmpeg
|
| 132 |
+
except Exception as exc:
|
| 133 |
+
logger.warning(f"failed to resolve bundled ffmpeg binary: {str(exc)}")
|
| 134 |
+
|
| 135 |
+
return "ffmpeg"
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
def _escape_ffmpeg_concat_path(file_path: str) -> str:
|
| 139 |
+
# concat demuxer 使用单引号包裹路径,路径中的单引号需要先转义。
|
| 140 |
+
return file_path.replace("'", "'\\''")
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
def concat_video_clips_with_ffmpeg(
|
| 144 |
+
clip_files: List[str], output_file: str, threads: int, output_dir: str
|
| 145 |
+
):
|
| 146 |
+
concat_list_file = os.path.join(output_dir, "ffmpeg-concat-list.txt")
|
| 147 |
+
with open(concat_list_file, "w", encoding="utf-8") as fp:
|
| 148 |
+
for clip_file in clip_files:
|
| 149 |
+
absolute_path = os.path.abspath(clip_file)
|
| 150 |
+
fp.write(f"file '{_escape_ffmpeg_concat_path(absolute_path)}'\n")
|
| 151 |
+
|
| 152 |
+
command = [
|
| 153 |
+
get_ffmpeg_binary(),
|
| 154 |
+
"-y",
|
| 155 |
+
"-f",
|
| 156 |
+
"concat",
|
| 157 |
+
"-safe",
|
| 158 |
+
"0",
|
| 159 |
+
"-i",
|
| 160 |
+
concat_list_file,
|
| 161 |
+
"-c:v",
|
| 162 |
+
video_codec,
|
| 163 |
+
"-threads",
|
| 164 |
+
str(threads or 2),
|
| 165 |
+
"-pix_fmt",
|
| 166 |
+
"yuv420p",
|
| 167 |
+
output_file,
|
| 168 |
+
]
|
| 169 |
+
|
| 170 |
+
try:
|
| 171 |
+
# 使用 ffmpeg 只做一次串联与编码,避免 MoviePy 逐段合并时反复重编码,
|
| 172 |
+
# 从而降低画质劣化与颜色偏移风险。
|
| 173 |
+
result = subprocess.run(
|
| 174 |
+
command,
|
| 175 |
+
capture_output=True,
|
| 176 |
+
text=True,
|
| 177 |
+
check=False,
|
| 178 |
+
)
|
| 179 |
+
if result.returncode != 0:
|
| 180 |
+
error_message = (result.stderr or result.stdout or "").strip()
|
| 181 |
+
raise RuntimeError(error_message or "ffmpeg concat failed")
|
| 182 |
+
finally:
|
| 183 |
+
delete_files(concat_list_file)
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
def _sanitize_image_file(image_path: str) -> str:
|
| 187 |
+
# 某些本地图片虽然能被 Pillow 打开,但会因为损坏的 EXIF/eXIf 元数据导致
|
| 188 |
+
# ImageClip 在解析阶段直接抛异常。这里重新导出一份“干净图片”,把坏元数据剥离掉。
|
| 189 |
+
image_root, _ = os.path.splitext(image_path)
|
| 190 |
+
sanitized_path = f"{image_root}.sanitized.png"
|
| 191 |
+
|
| 192 |
+
with Image.open(image_path) as image:
|
| 193 |
+
image.load()
|
| 194 |
+
# 统一导出为 PNG,避免 JPEG/PNG 不同元数据路径继续把坏块带过去。
|
| 195 |
+
cleaned_image = Image.new(image.mode, image.size)
|
| 196 |
+
cleaned_image.putdata(list(image.getdata()))
|
| 197 |
+
cleaned_image.save(sanitized_path)
|
| 198 |
+
|
| 199 |
+
return sanitized_path
|
| 200 |
+
|
| 201 |
+
|
| 202 |
+
def _open_image_clip_with_fallback(image_path: str):
|
| 203 |
+
# 优先直接打开原始图片;如果因为损坏元数据失败,再尝试生成无元数据副本。
|
| 204 |
+
try:
|
| 205 |
+
return ImageClip(image_path), image_path
|
| 206 |
+
except Exception as exc:
|
| 207 |
+
logger.warning(
|
| 208 |
+
f"failed to open image directly, trying sanitized copy: {image_path}, error: {str(exc)}"
|
| 209 |
+
)
|
| 210 |
+
sanitized_path = _sanitize_image_file(image_path)
|
| 211 |
+
return ImageClip(sanitized_path), sanitized_path
|
| 212 |
+
|
| 213 |
+
|
| 214 |
+
def _open_video_clip_quietly(video_path: str, audio: bool = False) -> VideoFileClip:
|
| 215 |
+
"""
|
| 216 |
+
安静地打开视频文件,避免 MoviePy 2.1.x 把 ffmpeg 探测信息直接打印到 stdout。
|
| 217 |
+
|
| 218 |
+
背景:
|
| 219 |
+
当前依赖版本的 `FFMPEG_VideoReader` 内部存在 `print(self.infos)` 和
|
| 220 |
+
`print(ffmpeg command)`,读取无音轨的中间视频时会输出
|
| 221 |
+
`audio_found: False`。这只是输入素材 metadata,不代表最终成片没有音频,
|
| 222 |
+
但会误导 WebUI/终端用户以为生成失败。
|
| 223 |
+
|
| 224 |
+
实现:
|
| 225 |
+
1. 只在打开 VideoFileClip 的短窗口内重定向 stdout;
|
| 226 |
+
2. 默认 `audio=False`,因为项目视频素材阶段不需要保留素材原声,
|
| 227 |
+
最终音频会在 `generate_video()` 阶段统一挂载;
|
| 228 |
+
3. 如果依赖库确实输出了内容,降级为 debug 日志,便于必要时排查。
|
| 229 |
+
"""
|
| 230 |
+
captured_stdout = io.StringIO()
|
| 231 |
+
with redirect_stdout(captured_stdout):
|
| 232 |
+
clip = VideoFileClip(video_path, audio=audio)
|
| 233 |
+
|
| 234 |
+
moviepy_stdout = captured_stdout.getvalue().strip()
|
| 235 |
+
if moviepy_stdout:
|
| 236 |
+
logger.debug(
|
| 237 |
+
"suppressed MoviePy video reader stdout for "
|
| 238 |
+
f"{video_path}, chars: {len(moviepy_stdout)}"
|
| 239 |
+
)
|
| 240 |
+
|
| 241 |
+
return clip
|
| 242 |
+
|
| 243 |
+
|
| 244 |
+
def close_clip(clip):
|
| 245 |
+
if clip is None:
|
| 246 |
+
return
|
| 247 |
+
|
| 248 |
+
try:
|
| 249 |
+
# close main resources
|
| 250 |
+
if hasattr(clip, 'reader') and clip.reader is not None:
|
| 251 |
+
clip.reader.close()
|
| 252 |
+
|
| 253 |
+
# close audio resources
|
| 254 |
+
if hasattr(clip, 'audio') and clip.audio is not None:
|
| 255 |
+
if hasattr(clip.audio, 'reader') and clip.audio.reader is not None:
|
| 256 |
+
clip.audio.reader.close()
|
| 257 |
+
del clip.audio
|
| 258 |
+
|
| 259 |
+
# close mask resources
|
| 260 |
+
if hasattr(clip, 'mask') and clip.mask is not None:
|
| 261 |
+
if hasattr(clip.mask, 'reader') and clip.mask.reader is not None:
|
| 262 |
+
clip.mask.reader.close()
|
| 263 |
+
del clip.mask
|
| 264 |
+
|
| 265 |
+
# handle child clips in composite clips
|
| 266 |
+
if hasattr(clip, 'clips') and clip.clips:
|
| 267 |
+
for child_clip in clip.clips:
|
| 268 |
+
if child_clip is not clip: # avoid possible circular references
|
| 269 |
+
close_clip(child_clip)
|
| 270 |
+
|
| 271 |
+
# clear clip list
|
| 272 |
+
if hasattr(clip, 'clips'):
|
| 273 |
+
clip.clips = []
|
| 274 |
+
|
| 275 |
+
except Exception as e:
|
| 276 |
+
logger.error(f"failed to close clip: {str(e)}")
|
| 277 |
+
|
| 278 |
+
del clip
|
| 279 |
+
gc.collect()
|
| 280 |
+
|
| 281 |
+
def delete_files(files: List[str] | str):
|
| 282 |
+
if isinstance(files, str):
|
| 283 |
+
files = [files]
|
| 284 |
+
|
| 285 |
+
for file in files:
|
| 286 |
+
try:
|
| 287 |
+
os.remove(file)
|
| 288 |
+
except Exception as e:
|
| 289 |
+
logger.debug(f"failed to delete file {file}: {str(e)}")
|
| 290 |
+
|
| 291 |
+
|
| 292 |
+
def _resolve_bgm_file_path(song_dir: str, bgm_file: str) -> str:
|
| 293 |
+
# 背景音乐只允许读取 resource/songs 目录内的文件,避免用户输入任意路径后
|
| 294 |
+
# 被 MoviePy 打开。这里兼容两种常见输入:
|
| 295 |
+
# 1. output000.mp3:来自 BGM 列表或用户只填写文件名
|
| 296 |
+
# 2. ./resource/songs/output000.mp3:用户按项目目录结构填写的相对路径
|
| 297 |
+
# 两种写法最终都会再次通过 resource/songs 白名单校验,不能绕过目录限制。
|
| 298 |
+
try:
|
| 299 |
+
return file_security.resolve_path_within_directory(song_dir, bgm_file)
|
| 300 |
+
except ValueError as song_dir_exc:
|
| 301 |
+
if os.path.isabs(bgm_file):
|
| 302 |
+
raise song_dir_exc
|
| 303 |
+
|
| 304 |
+
project_relative_file = os.path.join(utils.root_dir(), bgm_file)
|
| 305 |
+
try:
|
| 306 |
+
return file_security.resolve_path_within_directory(
|
| 307 |
+
song_dir, project_relative_file
|
| 308 |
+
)
|
| 309 |
+
except ValueError as root_dir_exc:
|
| 310 |
+
raise ValueError(str(root_dir_exc)) from song_dir_exc
|
| 311 |
+
|
| 312 |
+
|
| 313 |
+
def get_bgm_file(bgm_type: str = "random", bgm_file: str = ""):
|
| 314 |
+
if not bgm_type:
|
| 315 |
+
return ""
|
| 316 |
+
|
| 317 |
+
if bgm_file:
|
| 318 |
+
song_dir = utils.song_dir()
|
| 319 |
+
try:
|
| 320 |
+
resolved_bgm_file = _resolve_bgm_file_path(song_dir, bgm_file)
|
| 321 |
+
except ValueError as exc:
|
| 322 |
+
# API 请求里的 bgm_file 来自用户输入,不能直接把任意绝对路径交给
|
| 323 |
+
# MoviePy 打开。这里强制限制到 resource/songs 目录,阻止读取
|
| 324 |
+
# /etc/passwd、配置文件、密钥等非背景音乐文件。
|
| 325 |
+
logger.warning(
|
| 326 |
+
f"reject unsafe bgm file: {bgm_file}, song_dir: {song_dir}, error: {str(exc)}"
|
| 327 |
+
)
|
| 328 |
+
return ""
|
| 329 |
+
|
| 330 |
+
if not resolved_bgm_file.lower().endswith(_BGM_EXTENSIONS):
|
| 331 |
+
logger.warning(f"reject unsupported bgm file extension: {resolved_bgm_file}")
|
| 332 |
+
return ""
|
| 333 |
+
|
| 334 |
+
return resolved_bgm_file
|
| 335 |
+
|
| 336 |
+
if bgm_type == "random":
|
| 337 |
+
suffix = "*.mp3"
|
| 338 |
+
song_dir = utils.song_dir()
|
| 339 |
+
files = glob.glob(os.path.join(song_dir, suffix))
|
| 340 |
+
# 当背景音乐目录为空时,直接回退为“不使用 BGM”,避免 random.choice([]) 抛异常。
|
| 341 |
+
if not files:
|
| 342 |
+
logger.warning(f"no bgm files found in song directory: {song_dir}")
|
| 343 |
+
return ""
|
| 344 |
+
return random.choice(files)
|
| 345 |
+
|
| 346 |
+
return ""
|
| 347 |
+
|
| 348 |
+
|
| 349 |
+
def combine_videos(
|
| 350 |
+
combined_video_path: str,
|
| 351 |
+
video_paths: List[str],
|
| 352 |
+
audio_file: str,
|
| 353 |
+
video_aspect: VideoAspect = VideoAspect.portrait,
|
| 354 |
+
video_concat_mode: VideoConcatMode = VideoConcatMode.random,
|
| 355 |
+
video_transition_mode: VideoTransitionMode = None,
|
| 356 |
+
max_clip_duration: int = 5,
|
| 357 |
+
threads: int = 2,
|
| 358 |
+
) -> str:
|
| 359 |
+
audio_clip = AudioFileClip(audio_file)
|
| 360 |
+
try:
|
| 361 |
+
# 这里只需要读取旁白音频时长来决定素材视频拼接长度;后续不会再使用
|
| 362 |
+
# audio_clip。读取完成后立即关闭,避免早退或异常路径泄漏文件句柄。
|
| 363 |
+
audio_duration = audio_clip.duration
|
| 364 |
+
finally:
|
| 365 |
+
close_clip(audio_clip)
|
| 366 |
+
logger.info(f"audio duration: {audio_duration} seconds")
|
| 367 |
+
logger.info(f"maximum clip duration: {max_clip_duration} seconds")
|
| 368 |
+
|
| 369 |
+
# 兼容 API 直接调用时未传转场模式的情况,避免后续访问 .value 时崩溃。
|
| 370 |
+
transition_value = getattr(video_transition_mode, "value", video_transition_mode)
|
| 371 |
+
output_dir = os.path.dirname(combined_video_path)
|
| 372 |
+
|
| 373 |
+
aspect = VideoAspect(video_aspect)
|
| 374 |
+
video_width, video_height = aspect.to_resolution()
|
| 375 |
+
|
| 376 |
+
processed_clips = []
|
| 377 |
+
subclipped_items = []
|
| 378 |
+
video_duration = 0
|
| 379 |
+
for video_path in video_paths:
|
| 380 |
+
clip = _open_video_clip_quietly(video_path)
|
| 381 |
+
clip_duration = clip.duration
|
| 382 |
+
clip_w, clip_h = clip.size
|
| 383 |
+
close_clip(clip)
|
| 384 |
+
|
| 385 |
+
start_time = 0
|
| 386 |
+
|
| 387 |
+
while start_time < clip_duration:
|
| 388 |
+
end_time = min(start_time + max_clip_duration, clip_duration)
|
| 389 |
+
|
| 390 |
+
# 保留所有有效分段。
|
| 391 |
+
# 这样既不会丢掉“整段视频本身就短于 max_clip_duration”的素材,
|
| 392 |
+
# 也不会吞掉长视频最后剩下的一小段尾部内容。
|
| 393 |
+
if end_time > start_time:
|
| 394 |
+
subclipped_items.append(
|
| 395 |
+
SubClippedVideoClip(
|
| 396 |
+
file_path=video_path,
|
| 397 |
+
start_time=start_time,
|
| 398 |
+
end_time=end_time,
|
| 399 |
+
width=clip_w,
|
| 400 |
+
height=clip_h,
|
| 401 |
+
source_file_path=video_path,
|
| 402 |
+
)
|
| 403 |
+
)
|
| 404 |
+
|
| 405 |
+
start_time = end_time
|
| 406 |
+
if video_concat_mode.value == VideoConcatMode.sequential.value:
|
| 407 |
+
break
|
| 408 |
+
|
| 409 |
+
subclipped_items = _prioritize_unique_source_clips(
|
| 410 |
+
subclipped_items=subclipped_items,
|
| 411 |
+
concat_mode=video_concat_mode,
|
| 412 |
+
)
|
| 413 |
+
|
| 414 |
+
logger.debug(f"total subclipped items: {len(subclipped_items)}")
|
| 415 |
+
|
| 416 |
+
# Add downloaded clips over and over until the duration of the audio (max_duration) has been reached
|
| 417 |
+
for i, subclipped_item in enumerate(subclipped_items):
|
| 418 |
+
if video_duration >= audio_duration:
|
| 419 |
+
break
|
| 420 |
+
|
| 421 |
+
logger.debug(
|
| 422 |
+
f"processing clip {i+1}: {subclipped_item.width}x{subclipped_item.height}, "
|
| 423 |
+
f"source: {os.path.basename(subclipped_item.source_file_path)}, "
|
| 424 |
+
f"current duration: {video_duration:.2f}s, "
|
| 425 |
+
f"remaining: {audio_duration - video_duration:.2f}s"
|
| 426 |
+
)
|
| 427 |
+
|
| 428 |
+
try:
|
| 429 |
+
clip = _open_video_clip_quietly(subclipped_item.file_path).subclipped(
|
| 430 |
+
subclipped_item.start_time, subclipped_item.end_time
|
| 431 |
+
)
|
| 432 |
+
clip_duration = clip.duration
|
| 433 |
+
# Not all videos are same size, so we need to resize them
|
| 434 |
+
clip_w, clip_h = clip.size
|
| 435 |
+
if clip_w != video_width or clip_h != video_height:
|
| 436 |
+
clip_ratio = clip.w / clip.h
|
| 437 |
+
video_ratio = video_width / video_height
|
| 438 |
+
logger.debug(f"resizing clip, source: {clip_w}x{clip_h}, ratio: {clip_ratio:.2f}, target: {video_width}x{video_height}, ratio: {video_ratio:.2f}")
|
| 439 |
+
|
| 440 |
+
if clip_ratio == video_ratio:
|
| 441 |
+
clip = clip.resized(new_size=(video_width, video_height))
|
| 442 |
+
else:
|
| 443 |
+
if clip_ratio > video_ratio:
|
| 444 |
+
scale_factor = video_width / clip_w
|
| 445 |
+
else:
|
| 446 |
+
scale_factor = video_height / clip_h
|
| 447 |
+
|
| 448 |
+
new_width = int(clip_w * scale_factor)
|
| 449 |
+
new_height = int(clip_h * scale_factor)
|
| 450 |
+
|
| 451 |
+
background = ColorClip(size=(video_width, video_height), color=(0, 0, 0)).with_duration(clip_duration)
|
| 452 |
+
clip_resized = clip.resized(new_size=(new_width, new_height)).with_position("center")
|
| 453 |
+
clip = CompositeVideoClip([background, clip_resized])
|
| 454 |
+
|
| 455 |
+
shuffle_side = random.choice(["left", "right", "top", "bottom"])
|
| 456 |
+
if transition_value in (None, VideoTransitionMode.none.value):
|
| 457 |
+
clip = clip
|
| 458 |
+
elif transition_value == VideoTransitionMode.fade_in.value:
|
| 459 |
+
clip = video_effects.fadein_transition(clip, 1)
|
| 460 |
+
elif transition_value == VideoTransitionMode.fade_out.value:
|
| 461 |
+
clip = video_effects.fadeout_transition(clip, 1)
|
| 462 |
+
elif transition_value == VideoTransitionMode.slide_in.value:
|
| 463 |
+
clip = video_effects.slidein_transition(clip, 1, shuffle_side)
|
| 464 |
+
elif transition_value == VideoTransitionMode.slide_out.value:
|
| 465 |
+
clip = video_effects.slideout_transition(clip, 1, shuffle_side)
|
| 466 |
+
elif transition_value == VideoTransitionMode.shuffle.value:
|
| 467 |
+
transition_funcs = [
|
| 468 |
+
lambda c: video_effects.fadein_transition(c, 1),
|
| 469 |
+
lambda c: video_effects.fadeout_transition(c, 1),
|
| 470 |
+
lambda c: video_effects.slidein_transition(c, 1, shuffle_side),
|
| 471 |
+
lambda c: video_effects.slideout_transition(c, 1, shuffle_side),
|
| 472 |
+
]
|
| 473 |
+
shuffle_transition = random.choice(transition_funcs)
|
| 474 |
+
clip = shuffle_transition(clip)
|
| 475 |
+
|
| 476 |
+
if clip.duration > max_clip_duration:
|
| 477 |
+
clip = clip.subclipped(0, max_clip_duration)
|
| 478 |
+
|
| 479 |
+
# wirte clip to temp file
|
| 480 |
+
clip_file = f"{output_dir}/temp-clip-{i+1}.mp4"
|
| 481 |
+
clip.write_videofile(clip_file, logger=None, fps=fps, codec=video_codec)
|
| 482 |
+
|
| 483 |
+
# Store clip duration before closing
|
| 484 |
+
clip_duration_saved = clip.duration
|
| 485 |
+
close_clip(clip)
|
| 486 |
+
|
| 487 |
+
processed_clips.append(
|
| 488 |
+
SubClippedVideoClip(
|
| 489 |
+
file_path=clip_file,
|
| 490 |
+
duration=clip_duration_saved,
|
| 491 |
+
width=clip_w,
|
| 492 |
+
height=clip_h,
|
| 493 |
+
source_file_path=subclipped_item.source_file_path,
|
| 494 |
+
)
|
| 495 |
+
)
|
| 496 |
+
video_duration += clip_duration_saved
|
| 497 |
+
|
| 498 |
+
except Exception as e:
|
| 499 |
+
logger.error(f"failed to process clip: {str(e)}")
|
| 500 |
+
|
| 501 |
+
# loop processed clips until the video duration matches or exceeds the audio duration.
|
| 502 |
+
if video_duration < audio_duration:
|
| 503 |
+
logger.warning(f"video duration ({video_duration:.2f}s) is shorter than audio duration ({audio_duration:.2f}s), looping clips to match audio length.")
|
| 504 |
+
base_clips = processed_clips.copy()
|
| 505 |
+
for clip in itertools.cycle(base_clips):
|
| 506 |
+
if video_duration >= audio_duration:
|
| 507 |
+
break
|
| 508 |
+
processed_clips.append(clip)
|
| 509 |
+
video_duration += clip.duration
|
| 510 |
+
logger.info(f"video duration: {video_duration:.2f}s, audio duration: {audio_duration:.2f}s, looped {len(processed_clips)-len(base_clips)} clips")
|
| 511 |
+
|
| 512 |
+
# merge video clips progressively, avoid loading all videos at once to avoid memory overflow
|
| 513 |
+
logger.info("starting clip merging process")
|
| 514 |
+
if not processed_clips:
|
| 515 |
+
logger.warning("no clips available for merging")
|
| 516 |
+
return combined_video_path
|
| 517 |
+
|
| 518 |
+
# if there is only one clip, use it directly
|
| 519 |
+
if len(processed_clips) == 1:
|
| 520 |
+
logger.info("using single clip directly")
|
| 521 |
+
shutil.copy(processed_clips[0].file_path, combined_video_path)
|
| 522 |
+
delete_files([processed_clips[0].file_path])
|
| 523 |
+
logger.info("video combining completed")
|
| 524 |
+
return combined_video_path
|
| 525 |
+
|
| 526 |
+
clip_files = [clip.file_path for clip in processed_clips]
|
| 527 |
+
logger.info(f"concatenating {len(clip_files)} clips with ffmpeg")
|
| 528 |
+
concat_video_clips_with_ffmpeg(
|
| 529 |
+
clip_files=clip_files,
|
| 530 |
+
output_file=combined_video_path,
|
| 531 |
+
threads=threads,
|
| 532 |
+
output_dir=output_dir,
|
| 533 |
+
)
|
| 534 |
+
|
| 535 |
+
# clean temp files
|
| 536 |
+
delete_files(clip_files)
|
| 537 |
+
|
| 538 |
+
logger.info("video combining completed")
|
| 539 |
+
return combined_video_path
|
| 540 |
+
|
| 541 |
+
|
| 542 |
+
def wrap_text(text, max_width, font="Arial", fontsize=60):
|
| 543 |
+
# 字幕换行必须在真正创建 TextClip 前完成,否则 MoviePy 只会按原始文本
|
| 544 |
+
# 计算渲染区域。这里用 PIL 按当前字体和字号测量宽度,确保每一行都尽量
|
| 545 |
+
# 控制在视频可用宽度内,避免大字号或中文长句直接溢出画面。
|
| 546 |
+
font = ImageFont.truetype(font, fontsize)
|
| 547 |
+
max_width = int(max_width)
|
| 548 |
+
|
| 549 |
+
def get_text_size(inner_text):
|
| 550 |
+
inner_text = inner_text.strip()
|
| 551 |
+
if not inner_text:
|
| 552 |
+
return 0, fontsize
|
| 553 |
+
left, top, right, bottom = font.getbbox(inner_text)
|
| 554 |
+
return right - left, bottom - top
|
| 555 |
+
|
| 556 |
+
width, height = get_text_size(text)
|
| 557 |
+
if width <= max_width:
|
| 558 |
+
return text, height
|
| 559 |
+
|
| 560 |
+
def split_long_token(token):
|
| 561 |
+
# 当一个 token 本身就超宽时(常见于中文无空格长句,或英文超长单词),
|
| 562 |
+
# 退化为字符级拆分。关键点是:检测到 candidate 超宽时,先提交上一个
|
| 563 |
+
# 仍然合法的 current,再把当前字符放入下一行,不能把超宽字符塞回上一行。
|
| 564 |
+
lines = []
|
| 565 |
+
current = ""
|
| 566 |
+
for char in token:
|
| 567 |
+
candidate = f"{current}{char}"
|
| 568 |
+
candidate_width, _ = get_text_size(candidate)
|
| 569 |
+
if candidate_width <= max_width or not current:
|
| 570 |
+
current = candidate
|
| 571 |
+
continue
|
| 572 |
+
lines.append(current)
|
| 573 |
+
current = char
|
| 574 |
+
if current:
|
| 575 |
+
lines.append(current)
|
| 576 |
+
return lines
|
| 577 |
+
|
| 578 |
+
lines = []
|
| 579 |
+
current = ""
|
| 580 |
+
words = text.split(" ")
|
| 581 |
+
for word in words:
|
| 582 |
+
candidate = f"{current} {word}".strip() if current else word
|
| 583 |
+
candidate_width, _ = get_text_size(candidate)
|
| 584 |
+
if candidate_width <= max_width:
|
| 585 |
+
current = candidate
|
| 586 |
+
continue
|
| 587 |
+
|
| 588 |
+
if current:
|
| 589 |
+
lines.append(current)
|
| 590 |
+
|
| 591 |
+
word_width, _ = get_text_size(word)
|
| 592 |
+
if word_width <= max_width:
|
| 593 |
+
current = word
|
| 594 |
+
else:
|
| 595 |
+
lines.extend(split_long_token(word))
|
| 596 |
+
current = ""
|
| 597 |
+
|
| 598 |
+
if current:
|
| 599 |
+
lines.append(current)
|
| 600 |
+
|
| 601 |
+
result = "\n".join(line.strip() for line in lines if line.strip()).strip()
|
| 602 |
+
height = len(lines) * height
|
| 603 |
+
return result, height
|
| 604 |
+
|
| 605 |
+
|
| 606 |
+
def _hex_to_rgb(color: str) -> tuple[int, int, int]:
|
| 607 |
+
# 字幕背景色来自 API/WebUI 参数,可能为空或格式不规范。这里统一只接受
|
| 608 |
+
# #RRGGBB 形式,非法值回退为黑色,避免 PIL 渲染阶段抛出异常中断任务。
|
| 609 |
+
if isinstance(color, str) and color.startswith("#") and len(color) == 7:
|
| 610 |
+
try:
|
| 611 |
+
return (int(color[1:3], 16), int(color[3:5], 16), int(color[5:7], 16))
|
| 612 |
+
except ValueError:
|
| 613 |
+
pass
|
| 614 |
+
return (0, 0, 0)
|
| 615 |
+
|
| 616 |
+
|
| 617 |
+
def _rounded_subtitle_background_clip(
|
| 618 |
+
width: int,
|
| 619 |
+
height: int,
|
| 620 |
+
color: str,
|
| 621 |
+
alpha: int = 140,
|
| 622 |
+
radius: int = 16,
|
| 623 |
+
) -> ImageClip:
|
| 624 |
+
# 新字幕背景仅在用户显式开启时使用:通过 RGBA 图片绘制圆角半透明底板,
|
| 625 |
+
# 再交给 MoviePy 作为透明 ImageClip 参与合成。这样默认路径完全不变,
|
| 626 |
+
# 同时可以低成本试验更柔和的字幕视觉效果。
|
| 627 |
+
rgb = _hex_to_rgb(color)
|
| 628 |
+
safe_alpha = max(0, min(255, int(alpha)))
|
| 629 |
+
img = Image.new("RGBA", (width, height), (0, 0, 0, 0))
|
| 630 |
+
draw = ImageDraw.Draw(img)
|
| 631 |
+
draw.rounded_rectangle(
|
| 632 |
+
[0, 0, max(0, width - 1), max(0, height - 1)],
|
| 633 |
+
radius=max(0, int(radius)),
|
| 634 |
+
fill=(rgb[0], rgb[1], rgb[2], safe_alpha),
|
| 635 |
+
)
|
| 636 |
+
return ImageClip(np.array(img), transparent=True)
|
| 637 |
+
|
| 638 |
+
|
| 639 |
+
def generate_video(
|
| 640 |
+
video_path: str,
|
| 641 |
+
audio_path: str,
|
| 642 |
+
subtitle_path: str,
|
| 643 |
+
output_file: str,
|
| 644 |
+
params: VideoParams,
|
| 645 |
+
):
|
| 646 |
+
aspect = VideoAspect(params.video_aspect)
|
| 647 |
+
video_width, video_height = aspect.to_resolution()
|
| 648 |
+
|
| 649 |
+
logger.info(f"generating video: {video_width} x {video_height}")
|
| 650 |
+
logger.info(f" ① video: {video_path}")
|
| 651 |
+
logger.info(f" ② audio: {audio_path}")
|
| 652 |
+
logger.info(f" ③ subtitle: {subtitle_path}")
|
| 653 |
+
logger.info(f" ④ output: {output_file}")
|
| 654 |
+
|
| 655 |
+
# https://github.com/harry0703/MoneyPrinterTurbo/issues/217
|
| 656 |
+
# PermissionError: [WinError 32] The process cannot access the file because it is being used by another process: 'final-1.mp4.tempTEMP_MPY_wvf_snd.mp3'
|
| 657 |
+
# write into the same directory as the output file
|
| 658 |
+
output_dir = os.path.dirname(output_file)
|
| 659 |
+
|
| 660 |
+
font_path = ""
|
| 661 |
+
if params.subtitle_enabled:
|
| 662 |
+
if not params.font_name:
|
| 663 |
+
params.font_name = "STHeitiMedium.ttc"
|
| 664 |
+
font_path = os.path.join(utils.font_dir(), params.font_name)
|
| 665 |
+
if os.name == "nt":
|
| 666 |
+
font_path = font_path.replace("\\", "/")
|
| 667 |
+
|
| 668 |
+
logger.info(f" ⑤ font: {font_path}")
|
| 669 |
+
|
| 670 |
+
def resolve_subtitle_background_color():
|
| 671 |
+
# 兼容历史参数:API 里 `text_background_color` 既可能是布尔值,
|
| 672 |
+
# 也可能是实际颜色字符串。统一在这里归一化,避免把 True/False
|
| 673 |
+
# 直接传给 TextClip 后出现不可预期的渲染结果。
|
| 674 |
+
if isinstance(params.text_background_color, bool):
|
| 675 |
+
return "#000000" if params.text_background_color else None
|
| 676 |
+
return params.text_background_color
|
| 677 |
+
|
| 678 |
+
def create_text_clip(subtitle_item):
|
| 679 |
+
params.font_size = int(params.font_size)
|
| 680 |
+
params.stroke_width = int(params.stroke_width)
|
| 681 |
+
phrase = subtitle_item[1]
|
| 682 |
+
max_width = video_width * 0.9
|
| 683 |
+
wrapped_txt, txt_height = wrap_text(
|
| 684 |
+
phrase, max_width=max_width, font=font_path, fontsize=params.font_size
|
| 685 |
+
)
|
| 686 |
+
interline = int(params.font_size * 0.25)
|
| 687 |
+
line_count = wrapped_txt.count("\n") + 1
|
| 688 |
+
vertical_padding = int(params.font_size * 0.35)
|
| 689 |
+
# MoviePy 在 `method=label` 下会自动收缩文本框高度,遇到多行字幕、
|
| 690 |
+
# 描边或背景色时,容易把最后一行的下半部分裁掉。这里显式传入
|
| 691 |
+
# 一个更保守的高度,把行间距和额外上下留白一并算进去,保证字幕
|
| 692 |
+
# 背景框与文字本身都能完整渲染出来。
|
| 693 |
+
clip_h = int(txt_height + vertical_padding + (interline * line_count))
|
| 694 |
+
bg_color = resolve_subtitle_background_color()
|
| 695 |
+
rounded_bg_enabled = bool(
|
| 696 |
+
getattr(params, "rounded_subtitle_background", False) and bg_color
|
| 697 |
+
)
|
| 698 |
+
|
| 699 |
+
if rounded_bg_enabled:
|
| 700 |
+
# 圆角背景需要贴合文字宽度,而不是沿用 90% 视频宽度。这里先用
|
| 701 |
+
# PIL 测量最长一行文字,再加水平内边距,避免短字幕出现过宽底板。
|
| 702 |
+
try:
|
| 703 |
+
font = ImageFont.truetype(font_path, params.font_size)
|
| 704 |
+
text_w = max(
|
| 705 |
+
int(font.getbbox(line)[2] - font.getbbox(line)[0])
|
| 706 |
+
for line in wrapped_txt.split("\n")
|
| 707 |
+
)
|
| 708 |
+
except Exception as exc:
|
| 709 |
+
logger.warning(
|
| 710 |
+
f"failed to measure subtitle text width, fallback to max width: {str(exc)}"
|
| 711 |
+
)
|
| 712 |
+
text_w = int(max_width)
|
| 713 |
+
|
| 714 |
+
pad_x = int(params.font_size * 0.6)
|
| 715 |
+
box_w = max(1, min(int(max_width), text_w + 2 * pad_x))
|
| 716 |
+
radius = max(8, int(params.font_size * 0.4))
|
| 717 |
+
text_clip = TextClip(
|
| 718 |
+
text=wrapped_txt,
|
| 719 |
+
font=font_path,
|
| 720 |
+
font_size=params.font_size,
|
| 721 |
+
color=params.text_fore_color,
|
| 722 |
+
bg_color=None,
|
| 723 |
+
stroke_color=params.stroke_color,
|
| 724 |
+
stroke_width=params.stroke_width,
|
| 725 |
+
interline=interline,
|
| 726 |
+
size=(box_w, clip_h),
|
| 727 |
+
text_align="center",
|
| 728 |
+
)
|
| 729 |
+
bg_clip = _rounded_subtitle_background_clip(
|
| 730 |
+
width=box_w,
|
| 731 |
+
height=clip_h,
|
| 732 |
+
color=bg_color,
|
| 733 |
+
alpha=140,
|
| 734 |
+
radius=radius,
|
| 735 |
+
)
|
| 736 |
+
_clip = CompositeVideoClip(
|
| 737 |
+
[bg_clip, text_clip.with_position("center")],
|
| 738 |
+
size=(box_w, clip_h),
|
| 739 |
+
)
|
| 740 |
+
else:
|
| 741 |
+
size = (
|
| 742 |
+
int(max_width),
|
| 743 |
+
clip_h,
|
| 744 |
+
)
|
| 745 |
+
_clip = TextClip(
|
| 746 |
+
text=wrapped_txt,
|
| 747 |
+
font=font_path,
|
| 748 |
+
font_size=params.font_size,
|
| 749 |
+
color=params.text_fore_color,
|
| 750 |
+
bg_color=bg_color,
|
| 751 |
+
stroke_color=params.stroke_color,
|
| 752 |
+
stroke_width=params.stroke_width,
|
| 753 |
+
interline=interline,
|
| 754 |
+
size=size,
|
| 755 |
+
text_align="center",
|
| 756 |
+
)
|
| 757 |
+
duration = subtitle_item[0][1] - subtitle_item[0][0]
|
| 758 |
+
_clip = _clip.with_start(subtitle_item[0][0])
|
| 759 |
+
_clip = _clip.with_end(subtitle_item[0][1])
|
| 760 |
+
_clip = _clip.with_duration(duration)
|
| 761 |
+
if params.subtitle_position == "bottom":
|
| 762 |
+
_clip = _clip.with_position(("center", video_height * 0.95 - _clip.h))
|
| 763 |
+
elif params.subtitle_position == "top":
|
| 764 |
+
_clip = _clip.with_position(("center", video_height * 0.05))
|
| 765 |
+
elif params.subtitle_position == "custom":
|
| 766 |
+
# Ensure the subtitle is fully within the screen bounds
|
| 767 |
+
margin = 10 # Additional margin, in pixels
|
| 768 |
+
max_y = video_height - _clip.h - margin
|
| 769 |
+
min_y = margin
|
| 770 |
+
custom_y = (video_height - _clip.h) * (params.custom_position / 100)
|
| 771 |
+
custom_y = max(
|
| 772 |
+
min_y, min(custom_y, max_y)
|
| 773 |
+
) # Constrain the y value within the valid range
|
| 774 |
+
_clip = _clip.with_position(("center", custom_y))
|
| 775 |
+
else: # center
|
| 776 |
+
_clip = _clip.with_position(("center", "center"))
|
| 777 |
+
return _clip
|
| 778 |
+
|
| 779 |
+
video_clip = _open_video_clip_quietly(video_path)
|
| 780 |
+
audio_clip = AudioFileClip(audio_path).with_effects(
|
| 781 |
+
[afx.MultiplyVolume(params.voice_volume)]
|
| 782 |
+
)
|
| 783 |
+
|
| 784 |
+
def make_textclip(text):
|
| 785 |
+
return TextClip(
|
| 786 |
+
text=text,
|
| 787 |
+
font=font_path,
|
| 788 |
+
font_size=params.font_size,
|
| 789 |
+
)
|
| 790 |
+
|
| 791 |
+
if subtitle_path and os.path.exists(subtitle_path):
|
| 792 |
+
sub = SubtitlesClip(
|
| 793 |
+
subtitles=subtitle_path, encoding="utf-8", make_textclip=make_textclip
|
| 794 |
+
)
|
| 795 |
+
text_clips = []
|
| 796 |
+
for item in sub.subtitles:
|
| 797 |
+
clip = create_text_clip(subtitle_item=item)
|
| 798 |
+
text_clips.append(clip)
|
| 799 |
+
video_clip = CompositeVideoClip([video_clip, *text_clips])
|
| 800 |
+
|
| 801 |
+
bgm_file = get_bgm_file(bgm_type=params.bgm_type, bgm_file=params.bgm_file)
|
| 802 |
+
if bgm_file:
|
| 803 |
+
try:
|
| 804 |
+
bgm_clip = AudioFileClip(bgm_file).with_effects(
|
| 805 |
+
[
|
| 806 |
+
afx.MultiplyVolume(params.bgm_volume),
|
| 807 |
+
afx.AudioFadeOut(3),
|
| 808 |
+
afx.AudioLoop(duration=video_clip.duration),
|
| 809 |
+
]
|
| 810 |
+
)
|
| 811 |
+
audio_clip = CompositeAudioClip([audio_clip, bgm_clip])
|
| 812 |
+
except Exception as e:
|
| 813 |
+
logger.error(f"failed to add bgm: {str(e)}")
|
| 814 |
+
|
| 815 |
+
video_clip = video_clip.with_audio(audio_clip)
|
| 816 |
+
# 显式沿用输入音频的采样率;如果取不到,再回退到 MoviePy 默认的 44100Hz。
|
| 817 |
+
# 这样可以减少不同运行环境,尤其是 Docker 环境中再次重采样带来的音质波动。
|
| 818 |
+
output_audio_fps = int(getattr(audio_clip, "fps", 0) or 44100)
|
| 819 |
+
video_clip.write_videofile(
|
| 820 |
+
output_file,
|
| 821 |
+
audio_codec=audio_codec,
|
| 822 |
+
audio_fps=output_audio_fps,
|
| 823 |
+
audio_bitrate=audio_bitrate,
|
| 824 |
+
temp_audiofile_path=output_dir,
|
| 825 |
+
threads=params.n_threads or 2,
|
| 826 |
+
logger=None,
|
| 827 |
+
fps=fps,
|
| 828 |
+
)
|
| 829 |
+
video_clip.close()
|
| 830 |
+
del video_clip
|
| 831 |
+
|
| 832 |
+
|
| 833 |
+
def preprocess_video(materials: List[MaterialInfo], clip_duration=4):
|
| 834 |
+
# WebUI 在某些二次生成场景下可能传入空素材列表,这里直接返回空结果,避免抛出 NoneType 异常。
|
| 835 |
+
if not materials:
|
| 836 |
+
return []
|
| 837 |
+
|
| 838 |
+
# 仅返回通过预处理校验的素材,避免低分辨率图片继续进入后续的视频合成流程。
|
| 839 |
+
valid_materials = []
|
| 840 |
+
local_videos_dir = utils.storage_dir("local_videos", create=True)
|
| 841 |
+
|
| 842 |
+
for material in materials:
|
| 843 |
+
if not material.url:
|
| 844 |
+
continue
|
| 845 |
+
|
| 846 |
+
try:
|
| 847 |
+
material_source_path = file_security.resolve_path_within_directory(
|
| 848 |
+
local_videos_dir, material.url
|
| 849 |
+
)
|
| 850 |
+
except ValueError as exc:
|
| 851 |
+
# local video_source 的素材路径来自 API 参数,必须限制在专用素材目录。
|
| 852 |
+
# 允许用户传文件名,也兼容历史返回的绝对路径,但不允许逃逸到系统
|
| 853 |
+
# 其他目录,避免任意文件读取或通过 MoviePy 探测本地敏感文件。
|
| 854 |
+
logger.warning(
|
| 855 |
+
f"skip unsafe local material: {material.url}, "
|
| 856 |
+
f"local_videos_dir: {local_videos_dir}, error: {str(exc)}"
|
| 857 |
+
)
|
| 858 |
+
continue
|
| 859 |
+
|
| 860 |
+
ext = utils.parse_extension(material_source_path)
|
| 861 |
+
try:
|
| 862 |
+
# 图片素材直接按图片方式读取,避免先走 VideoFileClip 误判后触发不稳定的回退分支。
|
| 863 |
+
if ext in const.FILE_TYPE_IMAGES:
|
| 864 |
+
clip, material_source_path = _open_image_clip_with_fallback(
|
| 865 |
+
material_source_path
|
| 866 |
+
)
|
| 867 |
+
else:
|
| 868 |
+
clip = _open_video_clip_quietly(material_source_path)
|
| 869 |
+
except Exception:
|
| 870 |
+
# 非标准扩展名或探测失败时再回退到图片模式,兼容历史上直接传本地图片路径的情况。
|
| 871 |
+
try:
|
| 872 |
+
clip, material_source_path = _open_image_clip_with_fallback(
|
| 873 |
+
material_source_path
|
| 874 |
+
)
|
| 875 |
+
except Exception as exc:
|
| 876 |
+
logger.warning(
|
| 877 |
+
f"skip unreadable local material: {material.url}, error: {str(exc)}"
|
| 878 |
+
)
|
| 879 |
+
continue
|
| 880 |
+
try:
|
| 881 |
+
width = clip.size[0]
|
| 882 |
+
height = clip.size[1]
|
| 883 |
+
if width < 480 or height < 480:
|
| 884 |
+
logger.warning(f"low resolution material: {width}x{height}, minimum 480x480 required")
|
| 885 |
+
# 探测到低分辨率素材后立即关闭资源,并且不要把该素材返回给后续流程。
|
| 886 |
+
close_clip(clip)
|
| 887 |
+
continue
|
| 888 |
+
|
| 889 |
+
if ext in const.FILE_TYPE_IMAGES:
|
| 890 |
+
logger.info(f"processing image: {material_source_path}")
|
| 891 |
+
# 探测尺寸时已经打开过一次素材,这里先释放探测句柄,再重新创建用于导出的图片 clip。
|
| 892 |
+
close_clip(clip)
|
| 893 |
+
# Create an image clip and set its duration to 3 seconds
|
| 894 |
+
clip = (
|
| 895 |
+
ImageClip(material_source_path)
|
| 896 |
+
.with_duration(clip_duration)
|
| 897 |
+
.with_position("center")
|
| 898 |
+
)
|
| 899 |
+
# Apply a zoom effect using the resize method.
|
| 900 |
+
# A lambda function is used to make the zoom effect dynamic over time.
|
| 901 |
+
# The zoom effect starts from the original size and gradually scales up to 120%.
|
| 902 |
+
# t represents the current time, and clip.duration is the total duration of the clip (3 seconds).
|
| 903 |
+
# Note: 1 represents 100% size, so 1.2 represents 120% size.
|
| 904 |
+
zoom_clip = clip.resized(
|
| 905 |
+
lambda t: 1 + (clip_duration * 0.03) * (t / clip.duration)
|
| 906 |
+
)
|
| 907 |
+
|
| 908 |
+
# Optionally, create a composite video clip containing the zoomed clip.
|
| 909 |
+
# This is useful when you want to add other elements to the video.
|
| 910 |
+
final_clip = CompositeVideoClip([zoom_clip])
|
| 911 |
+
|
| 912 |
+
# Output the video to a file.
|
| 913 |
+
video_file = f"{material_source_path}.mp4"
|
| 914 |
+
final_clip.write_videofile(video_file, fps=30, logger=None)
|
| 915 |
+
close_clip(clip)
|
| 916 |
+
close_clip(final_clip)
|
| 917 |
+
material.url = video_file
|
| 918 |
+
logger.success(f"image processed: {video_file}")
|
| 919 |
+
else:
|
| 920 |
+
# 普通视频素材只需要读取尺寸做校验,校验完成后立即释放句柄即可。
|
| 921 |
+
close_clip(clip)
|
| 922 |
+
except Exception:
|
| 923 |
+
close_clip(clip)
|
| 924 |
+
raise
|
| 925 |
+
|
| 926 |
+
valid_materials.append(material)
|
| 927 |
+
|
| 928 |
+
return valid_materials
|
app/services/voice.py
ADDED
|
@@ -0,0 +1,1400 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import asyncio
|
| 2 |
+
import base64
|
| 3 |
+
import io
|
| 4 |
+
import inspect
|
| 5 |
+
import json
|
| 6 |
+
import math
|
| 7 |
+
import os
|
| 8 |
+
import queue
|
| 9 |
+
import re
|
| 10 |
+
import shutil
|
| 11 |
+
import threading
|
| 12 |
+
import time
|
| 13 |
+
from datetime import datetime
|
| 14 |
+
from typing import Union
|
| 15 |
+
from xml.sax.saxutils import unescape
|
| 16 |
+
|
| 17 |
+
import edge_tts
|
| 18 |
+
import requests
|
| 19 |
+
from edge_tts import SubMaker
|
| 20 |
+
from loguru import logger
|
| 21 |
+
from moviepy.video.tools import subtitles
|
| 22 |
+
from moviepy.audio.io.AudioFileClip import AudioFileClip
|
| 23 |
+
from openai import OpenAI
|
| 24 |
+
|
| 25 |
+
from app.config import config
|
| 26 |
+
from app.utils import utils
|
| 27 |
+
|
| 28 |
+
_DEFAULT_EDGE_TTS_TIMEOUT_SECONDS = 30.0
|
| 29 |
+
_MIMO_DEFAULT_BASE_URL = "https://api.xiaomimimo.com/v1"
|
| 30 |
+
_MIMO_DEFAULT_TTS_MODEL = "mimo-v2.5-tts"
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def _configure_pydub_ffmpeg(audio_segment_cls):
|
| 34 |
+
configured_ffmpeg = os.environ.get("IMAGEIO_FFMPEG_EXE") or shutil.which("ffmpeg")
|
| 35 |
+
if not configured_ffmpeg:
|
| 36 |
+
try:
|
| 37 |
+
import imageio_ffmpeg
|
| 38 |
+
|
| 39 |
+
configured_ffmpeg = imageio_ffmpeg.get_ffmpeg_exe()
|
| 40 |
+
except Exception as exc:
|
| 41 |
+
logger.warning(f"failed to resolve bundled ffmpeg binary: {str(exc)}")
|
| 42 |
+
|
| 43 |
+
if configured_ffmpeg:
|
| 44 |
+
audio_segment_cls.converter = configured_ffmpeg
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def mktimestamp(time_unit: float) -> str:
|
| 48 |
+
"""
|
| 49 |
+
将 edge_tts 使用的 100 纳秒时间单位转换为字幕时间戳。
|
| 50 |
+
|
| 51 |
+
edge_tts 7.x 不再导出旧版本里的 `mktimestamp`,但项目里旧字幕链路
|
| 52 |
+
还需要这个格式化函数来兼容 Azure v2、Gemini、SiliconFlow 这些
|
| 53 |
+
手工构造的字幕时间轴,因此这里内置一个等价实现。
|
| 54 |
+
"""
|
| 55 |
+
hour = math.floor(time_unit / 10**7 / 3600)
|
| 56 |
+
minute = math.floor((time_unit / 10**7 / 60) % 60)
|
| 57 |
+
seconds = (time_unit / 10**7) % 60
|
| 58 |
+
return f"{hour:02d}:{minute:02d}:{seconds:06.3f}"
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def get_siliconflow_voices() -> list[str]:
|
| 62 |
+
"""
|
| 63 |
+
获取硅基流动的声音列表
|
| 64 |
+
|
| 65 |
+
Returns:
|
| 66 |
+
声音列表,格式为 ["siliconflow:FunAudioLLM/CosyVoice2-0.5B:alex", ...]
|
| 67 |
+
"""
|
| 68 |
+
# 硅基流动的声音列表和对应的性别(用于显示)
|
| 69 |
+
voices_with_gender = [
|
| 70 |
+
("FunAudioLLM/CosyVoice2-0.5B", "alex", "Male"),
|
| 71 |
+
("FunAudioLLM/CosyVoice2-0.5B", "anna", "Female"),
|
| 72 |
+
("FunAudioLLM/CosyVoice2-0.5B", "bella", "Female"),
|
| 73 |
+
("FunAudioLLM/CosyVoice2-0.5B", "benjamin", "Male"),
|
| 74 |
+
("FunAudioLLM/CosyVoice2-0.5B", "charles", "Male"),
|
| 75 |
+
("FunAudioLLM/CosyVoice2-0.5B", "claire", "Female"),
|
| 76 |
+
("FunAudioLLM/CosyVoice2-0.5B", "david", "Male"),
|
| 77 |
+
("FunAudioLLM/CosyVoice2-0.5B", "diana", "Female"),
|
| 78 |
+
]
|
| 79 |
+
|
| 80 |
+
# 添加siliconflow:前缀,并格式化为显示名称
|
| 81 |
+
return [
|
| 82 |
+
f"siliconflow:{model}:{voice}-{gender}"
|
| 83 |
+
for model, voice, gender in voices_with_gender
|
| 84 |
+
]
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def get_gemini_voices() -> list[str]:
|
| 88 |
+
"""
|
| 89 |
+
获取Gemini TTS的声音列表
|
| 90 |
+
|
| 91 |
+
Returns:
|
| 92 |
+
声音列表,格式为 ["gemini:Zephyr-Female", "gemini:Puck-Male", ...]
|
| 93 |
+
"""
|
| 94 |
+
# Gemini TTS支持的语音列表
|
| 95 |
+
voices_with_gender = [
|
| 96 |
+
("Zephyr", "Female"),
|
| 97 |
+
("Puck", "Male"),
|
| 98 |
+
("Charon", "Male"),
|
| 99 |
+
("Kore", "Female"),
|
| 100 |
+
("Fenrir", "Male"),
|
| 101 |
+
("Aoede", "Female"),
|
| 102 |
+
("Thalia", "Female"),
|
| 103 |
+
("Sage", "Male"),
|
| 104 |
+
("Echo", "Female"),
|
| 105 |
+
("Harmony", "Female"),
|
| 106 |
+
("Lux", "Female"),
|
| 107 |
+
("Nova", "Female"),
|
| 108 |
+
("Vale", "Male"),
|
| 109 |
+
("Orion", "Male"),
|
| 110 |
+
("Atlas", "Male"),
|
| 111 |
+
]
|
| 112 |
+
|
| 113 |
+
# 添加gemini:前缀,并格式化为显示名称
|
| 114 |
+
return [
|
| 115 |
+
f"gemini:{voice}-{gender}"
|
| 116 |
+
for voice, gender in voices_with_gender
|
| 117 |
+
]
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
def get_mimo_voices() -> list[str]:
|
| 121 |
+
"""
|
| 122 |
+
获取 Xiaomi MiMo V2.5 TTS 的预置音色列表。
|
| 123 |
+
|
| 124 |
+
当前只接入官方文档里的 `mimo-v2.5-tts` 预置音色模式。音色设计
|
| 125 |
+
`mimo-v2.5-tts-voicedesign` 和音色复刻 `mimo-v2.5-tts-voiceclone`
|
| 126 |
+
需要额外的输入表单和素材上传流程,先不混入普通 TTS 下拉框,避免
|
| 127 |
+
用户误以为选择一个 voice id 就能完成所有高级能力。
|
| 128 |
+
"""
|
| 129 |
+
voices_with_gender = [
|
| 130 |
+
("mimo_default", "Female"),
|
| 131 |
+
("冰糖", "Female"),
|
| 132 |
+
("茉莉", "Female"),
|
| 133 |
+
("苏打", "Male"),
|
| 134 |
+
("白桦", "Male"),
|
| 135 |
+
("Mia", "Female"),
|
| 136 |
+
("Chloe", "Female"),
|
| 137 |
+
("Milo", "Male"),
|
| 138 |
+
("Dean", "Male"),
|
| 139 |
+
]
|
| 140 |
+
|
| 141 |
+
return [f"mimo:{voice}-{gender}" for voice, gender in voices_with_gender]
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
_AZURE_VOICES_DATA_FILE = os.path.join(
|
| 145 |
+
os.path.dirname(__file__), "data", "azure_voices.json"
|
| 146 |
+
)
|
| 147 |
+
_azure_voices_cache = None
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
def _load_azure_voices() -> list[dict]:
|
| 151 |
+
global _azure_voices_cache
|
| 152 |
+
if _azure_voices_cache is None:
|
| 153 |
+
with open(_AZURE_VOICES_DATA_FILE, "r", encoding="utf-8") as f:
|
| 154 |
+
_azure_voices_cache = json.load(f)
|
| 155 |
+
return _azure_voices_cache
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
def get_all_azure_voices(filter_locals=None) -> list[str]:
|
| 159 |
+
voices = []
|
| 160 |
+
for item in _load_azure_voices():
|
| 161 |
+
name = item["name"]
|
| 162 |
+
gender = item["gender"]
|
| 163 |
+
# 应用过滤���件
|
| 164 |
+
if filter_locals and any(
|
| 165 |
+
name.lower().startswith(fl.lower()) for fl in filter_locals
|
| 166 |
+
):
|
| 167 |
+
voices.append(f"{name}-{gender}")
|
| 168 |
+
elif not filter_locals:
|
| 169 |
+
voices.append(f"{name}-{gender}")
|
| 170 |
+
|
| 171 |
+
voices.sort()
|
| 172 |
+
return voices
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
def parse_voice_name(name: str):
|
| 176 |
+
# zh-CN-XiaoyiNeural-Female
|
| 177 |
+
# zh-CN-YunxiNeural-Male
|
| 178 |
+
# zh-CN-XiaoxiaoMultilingualNeural-V2-Female
|
| 179 |
+
name = name.replace("-Female", "").replace("-Male", "").strip()
|
| 180 |
+
return name
|
| 181 |
+
|
| 182 |
+
|
| 183 |
+
def is_azure_v2_voice(voice_name: str):
|
| 184 |
+
voice_name = parse_voice_name(voice_name)
|
| 185 |
+
if voice_name.endswith("-V2"):
|
| 186 |
+
return voice_name.replace("-V2", "").strip()
|
| 187 |
+
return ""
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
def is_siliconflow_voice(voice_name: str):
|
| 191 |
+
"""检查是否是硅基流动的声音"""
|
| 192 |
+
return voice_name.startswith("siliconflow:")
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
def is_gemini_voice(voice_name: str):
|
| 196 |
+
"""检查是否是Gemini TTS的声音"""
|
| 197 |
+
return voice_name.startswith("gemini:")
|
| 198 |
+
|
| 199 |
+
|
| 200 |
+
def is_mimo_voice(voice_name: str):
|
| 201 |
+
"""检查是否是 Xiaomi MiMo TTS 的声音"""
|
| 202 |
+
return voice_name.startswith("mimo:")
|
| 203 |
+
|
| 204 |
+
|
| 205 |
+
def tts(
|
| 206 |
+
text: str,
|
| 207 |
+
voice_name: str,
|
| 208 |
+
voice_rate: float,
|
| 209 |
+
voice_file: str,
|
| 210 |
+
voice_volume: float = 1.0,
|
| 211 |
+
) -> Union[SubMaker, None]:
|
| 212 |
+
if is_azure_v2_voice(voice_name):
|
| 213 |
+
return azure_tts_v2(text, voice_name, voice_file)
|
| 214 |
+
elif is_siliconflow_voice(voice_name):
|
| 215 |
+
# 从voice_name中提取模型和声音
|
| 216 |
+
# 格式: siliconflow:model:voice-Gender
|
| 217 |
+
parts = voice_name.split(":")
|
| 218 |
+
if len(parts) >= 3:
|
| 219 |
+
model = parts[1]
|
| 220 |
+
# 移除性别后缀,例如 "alex-Male" -> "alex"
|
| 221 |
+
voice_with_gender = parts[2]
|
| 222 |
+
voice = voice_with_gender.split("-")[0]
|
| 223 |
+
# 构建完整的voice参数,格式为 "model:voice"
|
| 224 |
+
full_voice = f"{model}:{voice}"
|
| 225 |
+
return siliconflow_tts(
|
| 226 |
+
text, model, full_voice, voice_rate, voice_file, voice_volume
|
| 227 |
+
)
|
| 228 |
+
else:
|
| 229 |
+
logger.error(f"Invalid siliconflow voice name format: {voice_name}")
|
| 230 |
+
return None
|
| 231 |
+
elif is_gemini_voice(voice_name):
|
| 232 |
+
# 从voice_name中提取声音名称
|
| 233 |
+
# 格式: gemini:voice-Gender
|
| 234 |
+
parts = voice_name.split(":")
|
| 235 |
+
if len(parts) >= 2:
|
| 236 |
+
# 移除性别后缀,例如 "Zephyr-Female" -> "Zephyr"
|
| 237 |
+
voice_with_gender = parts[1]
|
| 238 |
+
voice = voice_with_gender.split("-")[0]
|
| 239 |
+
return gemini_tts(text, voice, voice_rate, voice_file, voice_volume)
|
| 240 |
+
else:
|
| 241 |
+
logger.error(f"Invalid gemini voice name format: {voice_name}")
|
| 242 |
+
return None
|
| 243 |
+
elif is_mimo_voice(voice_name):
|
| 244 |
+
# 从voice_name中提取声音名称
|
| 245 |
+
# 格式: mimo:voice-Gender;如果调用方已执行 parse_voice_name,
|
| 246 |
+
# 则可能是 mimo:voice。两种格式都兼容。
|
| 247 |
+
parts = voice_name.split(":")
|
| 248 |
+
if len(parts) >= 2:
|
| 249 |
+
voice_with_gender = parts[1]
|
| 250 |
+
voice = voice_with_gender.split("-")[0]
|
| 251 |
+
return mimo_tts(text, voice, voice_rate, voice_file, voice_volume)
|
| 252 |
+
else:
|
| 253 |
+
logger.error(f"Invalid mimo voice name format: {voice_name}")
|
| 254 |
+
return None
|
| 255 |
+
return azure_tts_v1(text, voice_name, voice_rate, voice_file)
|
| 256 |
+
|
| 257 |
+
|
| 258 |
+
def convert_rate_to_percent(rate: float) -> str:
|
| 259 |
+
# edge-tts requires a sign-prefixed percentage (e.g. "+0%", "-20%").
|
| 260 |
+
# Rounding can yield 0 for rates near but not equal to 1.0 (e.g. 1.004,
|
| 261 |
+
# 0.997); those must still be returned as "+0%", not the unsigned "0%"
|
| 262 |
+
# which edge-tts rejects with ValueError: Invalid rate '0%'.
|
| 263 |
+
percent = round((rate - 1.0) * 100)
|
| 264 |
+
if percent >= 0:
|
| 265 |
+
return f"+{percent}%"
|
| 266 |
+
return f"{percent}%"
|
| 267 |
+
|
| 268 |
+
|
| 269 |
+
def ensure_file_path_exists(file_path: str) -> None:
|
| 270 |
+
"""
|
| 271 |
+
确保输出文件所在目录一定存在。
|
| 272 |
+
|
| 273 |
+
这里单独做一层兜底,是因为 edge_tts 7.x 在真正发起网络请求之前,
|
| 274 |
+
就会先打开目标音频文件;如果目录不存在,会直接因为本地文件路径报错,
|
| 275 |
+
从而掩盖真正的 TTS 行为结果。
|
| 276 |
+
"""
|
| 277 |
+
dir_path = os.path.dirname(file_path)
|
| 278 |
+
if dir_path:
|
| 279 |
+
os.makedirs(dir_path, exist_ok=True)
|
| 280 |
+
|
| 281 |
+
|
| 282 |
+
def ensure_legacy_submaker_fields(sub_maker: SubMaker) -> SubMaker:
|
| 283 |
+
"""
|
| 284 |
+
为项目里仍然沿用旧字幕结构的调用方补齐兼容字段。
|
| 285 |
+
|
| 286 |
+
edge_tts 7.x 的 `SubMaker` 主要暴露 `cues/get_srt()`,但项目里 Azure v2、
|
| 287 |
+
Gemini、SiliconFlow 这些路径仍然会直接读写 `subs/offset`。这里统一补齐,
|
| 288 |
+
避免升级 edge_tts 后这些非 edge 路径被连带破坏。
|
| 289 |
+
"""
|
| 290 |
+
if not hasattr(sub_maker, "subs"):
|
| 291 |
+
sub_maker.subs = []
|
| 292 |
+
if not hasattr(sub_maker, "offset"):
|
| 293 |
+
sub_maker.offset = []
|
| 294 |
+
return sub_maker
|
| 295 |
+
|
| 296 |
+
|
| 297 |
+
def populate_legacy_submaker_with_full_text(
|
| 298 |
+
sub_maker: SubMaker, text: str, audio_duration_seconds: float
|
| 299 |
+
) -> SubMaker:
|
| 300 |
+
"""
|
| 301 |
+
用整段文本填充项目历史沿用的 `subs/offset` 字幕结构。
|
| 302 |
+
|
| 303 |
+
背景:
|
| 304 |
+
1. edge_tts 7.x 的 `SubMaker` 不再提供旧版本里的 `create_sub()`;
|
| 305 |
+
2. 项目里 Gemini、SiliconFlow 等非 edge 路径依然需要返回一个
|
| 306 |
+
带 `subs/offset` 的对象,供后续统一计算音频时长和生成字幕;
|
| 307 |
+
3. 对于拿不到逐词边界的 TTS 服务,需要至少按脚本断句切成多个片段,
|
| 308 |
+
这样后续 `subtitle_provider=edge` 的聚合逻辑才能继续工作,而不是
|
| 309 |
+
因为整段文本无法和脚本断句逐行匹配而回退 Whisper。
|
| 310 |
+
|
| 311 |
+
Args:
|
| 312 |
+
sub_maker: 需要写入兼容字段的字幕对象
|
| 313 |
+
text: 原始脚本文本
|
| 314 |
+
audio_duration_seconds: 音频总时长,单位秒
|
| 315 |
+
|
| 316 |
+
Returns:
|
| 317 |
+
已填充兼容字幕数据的 SubMaker 对象
|
| 318 |
+
"""
|
| 319 |
+
sub_maker = ensure_legacy_submaker_fields(sub_maker)
|
| 320 |
+
|
| 321 |
+
# 清空旧值,避免调用方重复复用对象时出现脏数据叠加。
|
| 322 |
+
sub_maker.subs = []
|
| 323 |
+
sub_maker.offset = []
|
| 324 |
+
|
| 325 |
+
normalized_text = (text or "").strip()
|
| 326 |
+
if not normalized_text:
|
| 327 |
+
return sub_maker
|
| 328 |
+
|
| 329 |
+
audio_duration_100ns = max(int(audio_duration_seconds * 10000000), 1)
|
| 330 |
+
|
| 331 |
+
# Gemini / SiliconFlow 这类路径拿不到逐词边界时,仍然尽量沿用项目
|
| 332 |
+
# 原来的“按标点断句 + 按字符数比例分配时长”的策略。这样既能让
|
| 333 |
+
# create_subtitle() 匹配脚本断句,也能避免再次回退 Whisper。
|
| 334 |
+
sentences = utils.split_string_by_punctuations(normalized_text)
|
| 335 |
+
if not sentences:
|
| 336 |
+
sentences = [normalized_text]
|
| 337 |
+
|
| 338 |
+
total_chars = sum(len(sentence) for sentence in sentences)
|
| 339 |
+
if total_chars <= 0:
|
| 340 |
+
sub_maker.subs.append(normalized_text)
|
| 341 |
+
sub_maker.offset.append((0, audio_duration_100ns))
|
| 342 |
+
return sub_maker
|
| 343 |
+
|
| 344 |
+
current_offset = 0
|
| 345 |
+
for index, sentence in enumerate(sentences):
|
| 346 |
+
cleaned_sentence = sentence.strip()
|
| 347 |
+
if not cleaned_sentence:
|
| 348 |
+
continue
|
| 349 |
+
|
| 350 |
+
# 前面的句子按字符数比例分配时长,最后一句兜底吃掉剩余时长,
|
| 351 |
+
# 避免整数取整导致总时长丢失或字幕结束时间短于音频。
|
| 352 |
+
if index == len(sentences) - 1:
|
| 353 |
+
sentence_end = audio_duration_100ns
|
| 354 |
+
else:
|
| 355 |
+
sentence_chars = len(cleaned_sentence)
|
| 356 |
+
sentence_duration = max(
|
| 357 |
+
int(audio_duration_100ns * (sentence_chars / total_chars)),
|
| 358 |
+
1,
|
| 359 |
+
)
|
| 360 |
+
sentence_end = min(current_offset + sentence_duration, audio_duration_100ns)
|
| 361 |
+
|
| 362 |
+
sub_maker.subs.append(cleaned_sentence)
|
| 363 |
+
sub_maker.offset.append((current_offset, sentence_end))
|
| 364 |
+
current_offset = sentence_end
|
| 365 |
+
|
| 366 |
+
return sub_maker
|
| 367 |
+
|
| 368 |
+
|
| 369 |
+
def create_edge_tts_communicate(
|
| 370 |
+
text: str, voice_name: str, rate_str: str
|
| 371 |
+
) -> edge_tts.Communicate:
|
| 372 |
+
"""
|
| 373 |
+
按当前已安装的 edge_tts 版本构造 Communicate 对象。
|
| 374 |
+
|
| 375 |
+
背景:
|
| 376 |
+
1. 主线代码已经升级到 edge_tts 7.x,并使用 `boundary` 参数拿到更细的边界事件;
|
| 377 |
+
2. 但 Windows 便携包如果更新失败,现场环境可能仍然停留在旧版 edge_tts;
|
| 378 |
+
3. 旧版 `Communicate.__init__()` 不接受 `boundary`,会直接抛出
|
| 379 |
+
`unexpected keyword argument 'boundary'`,导致整个 TTS 链路失败。
|
| 380 |
+
|
| 381 |
+
因此这里先根据构造函数签名探测当前版本支持的参数,再决定是否传入
|
| 382 |
+
`boundary`,让同一份代码同时兼容旧版和新版依赖。
|
| 383 |
+
"""
|
| 384 |
+
communicate_kwargs = {"rate": rate_str}
|
| 385 |
+
communicate_signature = inspect.signature(edge_tts.Communicate)
|
| 386 |
+
|
| 387 |
+
if "boundary" in communicate_signature.parameters:
|
| 388 |
+
communicate_kwargs["boundary"] = "WordBoundary"
|
| 389 |
+
|
| 390 |
+
return edge_tts.Communicate(text, voice_name, **communicate_kwargs)
|
| 391 |
+
|
| 392 |
+
|
| 393 |
+
def get_edge_tts_timeout_seconds() -> Union[float, None]:
|
| 394 |
+
"""
|
| 395 |
+
获取 Azure TTS V1 单次流式请求的超时时间。
|
| 396 |
+
|
| 397 |
+
背景:
|
| 398 |
+
Edge consumer TTS 在网络不通、服务端限流、voice 与文本语言不匹配等场景下,
|
| 399 |
+
可能长时间卡在 `stream_sync()` 内部,日志只停留在 `start`。这里提供一个
|
| 400 |
+
默认超时,避免 WebUI 任务长期无反馈。
|
| 401 |
+
|
| 402 |
+
使用方式:
|
| 403 |
+
- 默认 30 秒,覆盖常见短视频脚本的首包等待时间;
|
| 404 |
+
- 如用户处于慢网络或代理环境,可在 `config.toml` 里设置
|
| 405 |
+
`edge_tts_timeout = 60`;
|
| 406 |
+
- 设置为 0 或负数表示显式禁用超时,保留完全向后兼容。
|
| 407 |
+
"""
|
| 408 |
+
raw_timeout = config.app.get(
|
| 409 |
+
"edge_tts_timeout", _DEFAULT_EDGE_TTS_TIMEOUT_SECONDS
|
| 410 |
+
)
|
| 411 |
+
try:
|
| 412 |
+
timeout_seconds = float(raw_timeout)
|
| 413 |
+
except (TypeError, ValueError):
|
| 414 |
+
logger.warning(
|
| 415 |
+
"invalid edge_tts_timeout: "
|
| 416 |
+
f"{raw_timeout}, fallback to {_DEFAULT_EDGE_TTS_TIMEOUT_SECONDS}s"
|
| 417 |
+
)
|
| 418 |
+
timeout_seconds = _DEFAULT_EDGE_TTS_TIMEOUT_SECONDS
|
| 419 |
+
|
| 420 |
+
if timeout_seconds <= 0:
|
| 421 |
+
return None
|
| 422 |
+
|
| 423 |
+
return timeout_seconds
|
| 424 |
+
|
| 425 |
+
|
| 426 |
+
def _stream_edge_tts_sync_with_timeout(
|
| 427 |
+
communicate, on_chunk, timeout_seconds: float
|
| 428 |
+
) -> None:
|
| 429 |
+
"""
|
| 430 |
+
带总超时地消费 edge_tts 7.x 的同步流。
|
| 431 |
+
|
| 432 |
+
实现原因:
|
| 433 |
+
`stream_sync()` 本身是阻塞迭代器,网络层卡住时主线程无法及时恢复。
|
| 434 |
+
这里把阻塞迭代放到 daemon 线程中,主线程通过 Queue 获取 chunk,
|
| 435 |
+
到达超时时间后直接抛出 TimeoutError,让外层重试和错误日志继续工作。
|
| 436 |
+
|
| 437 |
+
注意:
|
| 438 |
+
daemon 线程只作为兜底保护使用,最多随 Azure TTS V1 的 3 次重试产生
|
| 439 |
+
少量残留线程;进程退出时会自动回收。相比 WebUI 任务永久卡住,这是
|
| 440 |
+
更可控的失败模式。
|
| 441 |
+
"""
|
| 442 |
+
stream_queue = queue.Queue()
|
| 443 |
+
done_marker = object()
|
| 444 |
+
|
| 445 |
+
def _produce_chunks():
|
| 446 |
+
try:
|
| 447 |
+
for chunk in communicate.stream_sync():
|
| 448 |
+
stream_queue.put(("chunk", chunk))
|
| 449 |
+
stream_queue.put(("done", done_marker))
|
| 450 |
+
except Exception as e:
|
| 451 |
+
stream_queue.put(("error", e))
|
| 452 |
+
|
| 453 |
+
thread = threading.Thread(target=_produce_chunks, daemon=True)
|
| 454 |
+
thread.start()
|
| 455 |
+
|
| 456 |
+
deadline = time.monotonic() + timeout_seconds
|
| 457 |
+
while True:
|
| 458 |
+
remaining_seconds = deadline - time.monotonic()
|
| 459 |
+
if remaining_seconds <= 0:
|
| 460 |
+
raise TimeoutError(
|
| 461 |
+
f"edge_tts stream timed out after {timeout_seconds:g}s"
|
| 462 |
+
)
|
| 463 |
+
|
| 464 |
+
try:
|
| 465 |
+
item_type, payload = stream_queue.get(
|
| 466 |
+
timeout=min(0.5, remaining_seconds)
|
| 467 |
+
)
|
| 468 |
+
except queue.Empty:
|
| 469 |
+
continue
|
| 470 |
+
|
| 471 |
+
if item_type == "chunk":
|
| 472 |
+
on_chunk(payload)
|
| 473 |
+
elif item_type == "error":
|
| 474 |
+
raise payload
|
| 475 |
+
elif item_type == "done":
|
| 476 |
+
return
|
| 477 |
+
|
| 478 |
+
|
| 479 |
+
def stream_edge_tts_chunks(
|
| 480 |
+
communicate, on_chunk, timeout_seconds: Union[float, None] = None
|
| 481 |
+
) -> None:
|
| 482 |
+
"""
|
| 483 |
+
统一消费 edge_tts 的同步流和旧版异步流。
|
| 484 |
+
|
| 485 |
+
edge_tts 7.x 提供 `stream_sync()`,可以在同步函数里直接迭代;
|
| 486 |
+
更早的版本通常只有异步 `stream()`。为了让 `azure_tts_v1()` 在
|
| 487 |
+
旧依赖残留场景下仍能继续工作,这里统一做一层流式兼容。
|
| 488 |
+
|
| 489 |
+
Args:
|
| 490 |
+
communicate: edge_tts.Communicate 实例
|
| 491 |
+
on_chunk: 每拿到一个事件块时执行的回调
|
| 492 |
+
timeout_seconds: 单次流式请求总超时;为 None 时不启用超时。
|
| 493 |
+
"""
|
| 494 |
+
if hasattr(communicate, "stream_sync"):
|
| 495 |
+
if timeout_seconds:
|
| 496 |
+
_stream_edge_tts_sync_with_timeout(
|
| 497 |
+
communicate, on_chunk, timeout_seconds
|
| 498 |
+
)
|
| 499 |
+
return
|
| 500 |
+
|
| 501 |
+
for chunk in communicate.stream_sync():
|
| 502 |
+
on_chunk(chunk)
|
| 503 |
+
return
|
| 504 |
+
|
| 505 |
+
if not hasattr(communicate, "stream"):
|
| 506 |
+
raise AttributeError("edge_tts communicate object has no stream method")
|
| 507 |
+
|
| 508 |
+
async def _consume_async_stream():
|
| 509 |
+
async for chunk in communicate.stream():
|
| 510 |
+
on_chunk(chunk)
|
| 511 |
+
|
| 512 |
+
# 这里显式创建独立事件循环,而不是复用外部上下文,目的是避免
|
| 513 |
+
# 在同步调用栈里遇到“当前线程没有事件循环”或跨线程复用循环的问题。
|
| 514 |
+
loop = asyncio.new_event_loop()
|
| 515 |
+
try:
|
| 516 |
+
if timeout_seconds:
|
| 517 |
+
loop.run_until_complete(
|
| 518 |
+
asyncio.wait_for(_consume_async_stream(), timeout=timeout_seconds)
|
| 519 |
+
)
|
| 520 |
+
else:
|
| 521 |
+
loop.run_until_complete(_consume_async_stream())
|
| 522 |
+
finally:
|
| 523 |
+
loop.close()
|
| 524 |
+
|
| 525 |
+
|
| 526 |
+
def azure_tts_v1(
|
| 527 |
+
text: str, voice_name: str, voice_rate: float, voice_file: str
|
| 528 |
+
) -> Union[SubMaker, None]:
|
| 529 |
+
voice_name = parse_voice_name(voice_name)
|
| 530 |
+
text = text.strip()
|
| 531 |
+
rate_str = convert_rate_to_percent(voice_rate)
|
| 532 |
+
for i in range(3):
|
| 533 |
+
try:
|
| 534 |
+
logger.info(f"start, voice name: {voice_name}, try: {i + 1}")
|
| 535 |
+
|
| 536 |
+
# 这里同时兼容 edge_tts 7.x 和旧版便携包里可能残留的老依赖:
|
| 537 |
+
# 1. 新版支持 `boundary` + `stream_sync()`
|
| 538 |
+
# 2. 旧版不支持 `boundary`,且通常只暴露异步 `stream()`
|
| 539 |
+
ensure_file_path_exists(voice_file)
|
| 540 |
+
communicate = create_edge_tts_communicate(text, voice_name, rate_str)
|
| 541 |
+
sub_maker = edge_tts.SubMaker()
|
| 542 |
+
timeout_seconds = get_edge_tts_timeout_seconds()
|
| 543 |
+
|
| 544 |
+
with open(voice_file, "wb") as file:
|
| 545 |
+
def _handle_chunk(chunk):
|
| 546 |
+
chunk_type = chunk["type"]
|
| 547 |
+
if chunk_type == "audio":
|
| 548 |
+
file.write(chunk["data"])
|
| 549 |
+
elif chunk_type in ["WordBoundary", "SentenceBoundary"]:
|
| 550 |
+
# 无论来自 7.x 的同步流,还是旧版异步流,只要事件结构
|
| 551 |
+
# 里仍有边界信息,就统一喂给 SubMaker,保证后续字幕链路
|
| 552 |
+
# 仍然走项目现有逻辑。
|
| 553 |
+
sub_maker.feed(chunk)
|
| 554 |
+
|
| 555 |
+
stream_edge_tts_chunks(
|
| 556 |
+
communicate, _handle_chunk, timeout_seconds=timeout_seconds
|
| 557 |
+
)
|
| 558 |
+
|
| 559 |
+
if not sub_maker.get_srt():
|
| 560 |
+
logger.warning("failed, sub_maker.get_srt() is empty")
|
| 561 |
+
continue
|
| 562 |
+
|
| 563 |
+
logger.info(f"completed, output file: {voice_file}")
|
| 564 |
+
return sub_maker
|
| 565 |
+
except Exception as e:
|
| 566 |
+
logger.error(f"failed, error: {str(e)}")
|
| 567 |
+
# TTS 流式写入如果在首包前超时或网络异常,会留下 0 字节音频文件。
|
| 568 |
+
# 这种文件既不可播放,也可能误导后续排查,因此失败后只清理空文件;
|
| 569 |
+
# 如果已经写入了部分数据,则保留现场文件,便于分析服务端返回内容。
|
| 570 |
+
if os.path.exists(voice_file) and os.path.getsize(voice_file) == 0:
|
| 571 |
+
try:
|
| 572 |
+
os.remove(voice_file)
|
| 573 |
+
except Exception as remove_error:
|
| 574 |
+
logger.warning(
|
| 575 |
+
"failed to remove empty tts file: "
|
| 576 |
+
f"{voice_file}, error: {str(remove_error)}"
|
| 577 |
+
)
|
| 578 |
+
return None
|
| 579 |
+
|
| 580 |
+
|
| 581 |
+
def siliconflow_tts(
|
| 582 |
+
text: str,
|
| 583 |
+
model: str,
|
| 584 |
+
voice: str,
|
| 585 |
+
voice_rate: float,
|
| 586 |
+
voice_file: str,
|
| 587 |
+
voice_volume: float = 1.0,
|
| 588 |
+
) -> Union[SubMaker, None]:
|
| 589 |
+
"""
|
| 590 |
+
使用硅基流动的API生成语音
|
| 591 |
+
|
| 592 |
+
Args:
|
| 593 |
+
text: 要转换为语音的文本
|
| 594 |
+
model: 模型名称,如 "FunAudioLLM/CosyVoice2-0.5B"
|
| 595 |
+
voice: 声音名称,如 "FunAudioLLM/CosyVoice2-0.5B:alex"
|
| 596 |
+
voice_rate: 语音速度,范围[0.25, 4.0]
|
| 597 |
+
voice_file: 输出的音频文件路径
|
| 598 |
+
voice_volume: 语音音量,范围[0.6, 5.0],需要转换为硅基流动的增益范围[-10, 10]
|
| 599 |
+
|
| 600 |
+
Returns:
|
| 601 |
+
SubMaker对象或None
|
| 602 |
+
"""
|
| 603 |
+
text = text.strip()
|
| 604 |
+
api_key = config.siliconflow.get("api_key", "")
|
| 605 |
+
|
| 606 |
+
if not api_key:
|
| 607 |
+
logger.error("SiliconFlow API key is not set")
|
| 608 |
+
return None
|
| 609 |
+
|
| 610 |
+
# 将voice_volume转换为硅基流动的增益范围
|
| 611 |
+
# 默认voice_volume为1.0,对应gain为0
|
| 612 |
+
gain = voice_volume - 1.0
|
| 613 |
+
# 确保gain在[-10, 10]范围内
|
| 614 |
+
gain = max(-10, min(10, gain))
|
| 615 |
+
|
| 616 |
+
url = "https://api.siliconflow.cn/v1/audio/speech"
|
| 617 |
+
|
| 618 |
+
payload = {
|
| 619 |
+
"model": model,
|
| 620 |
+
"input": text,
|
| 621 |
+
"voice": voice,
|
| 622 |
+
"response_format": "mp3",
|
| 623 |
+
"sample_rate": 32000,
|
| 624 |
+
"stream": False,
|
| 625 |
+
"speed": voice_rate,
|
| 626 |
+
"gain": gain,
|
| 627 |
+
}
|
| 628 |
+
|
| 629 |
+
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
|
| 630 |
+
|
| 631 |
+
for i in range(3): # 尝试3次
|
| 632 |
+
try:
|
| 633 |
+
logger.info(
|
| 634 |
+
f"start siliconflow tts, model: {model}, voice: {voice}, try: {i + 1}"
|
| 635 |
+
)
|
| 636 |
+
|
| 637 |
+
response = requests.post(url, json=payload, headers=headers)
|
| 638 |
+
|
| 639 |
+
if response.status_code == 200:
|
| 640 |
+
# 保存音频文件
|
| 641 |
+
with open(voice_file, "wb") as f:
|
| 642 |
+
f.write(response.content)
|
| 643 |
+
|
| 644 |
+
# 这里仍然沿用项目原有的字幕结构,因此需要补齐旧字段。
|
| 645 |
+
sub_maker = ensure_legacy_submaker_fields(SubMaker())
|
| 646 |
+
|
| 647 |
+
# 获取音频文件的实际长度
|
| 648 |
+
try:
|
| 649 |
+
# 尝试使用moviepy获取音频长度
|
| 650 |
+
from moviepy import AudioFileClip
|
| 651 |
+
|
| 652 |
+
audio_clip = AudioFileClip(voice_file)
|
| 653 |
+
audio_duration = audio_clip.duration
|
| 654 |
+
audio_clip.close()
|
| 655 |
+
|
| 656 |
+
# 将音频长度转换为100纳秒单位(与edge_tts兼容)
|
| 657 |
+
audio_duration_100ns = int(audio_duration * 10000000)
|
| 658 |
+
|
| 659 |
+
# 使用文本分割来创建更准确的字幕
|
| 660 |
+
# 将文本按标点符号分割成句子
|
| 661 |
+
sentences = utils.split_string_by_punctuations(text)
|
| 662 |
+
|
| 663 |
+
if sentences:
|
| 664 |
+
# 计算每个句子的大致时长(按字符数比例分配)
|
| 665 |
+
total_chars = sum(len(s) for s in sentences)
|
| 666 |
+
char_duration = (
|
| 667 |
+
audio_duration_100ns / total_chars if total_chars > 0 else 0
|
| 668 |
+
)
|
| 669 |
+
|
| 670 |
+
current_offset = 0
|
| 671 |
+
for sentence in sentences:
|
| 672 |
+
if not sentence.strip():
|
| 673 |
+
continue
|
| 674 |
+
|
| 675 |
+
# 计算当前句子的时长
|
| 676 |
+
sentence_chars = len(sentence)
|
| 677 |
+
sentence_duration = int(sentence_chars * char_duration)
|
| 678 |
+
|
| 679 |
+
# 添加到SubMaker
|
| 680 |
+
sub_maker.subs.append(sentence)
|
| 681 |
+
sub_maker.offset.append(
|
| 682 |
+
(current_offset, current_offset + sentence_duration)
|
| 683 |
+
)
|
| 684 |
+
|
| 685 |
+
# 更新偏移量
|
| 686 |
+
current_offset += sentence_duration
|
| 687 |
+
else:
|
| 688 |
+
# 如果无法分割,则使用整个文本作为一个字幕
|
| 689 |
+
sub_maker.subs = [text]
|
| 690 |
+
sub_maker.offset = [(0, audio_duration_100ns)]
|
| 691 |
+
|
| 692 |
+
except Exception as e:
|
| 693 |
+
logger.warning(f"Failed to create accurate subtitles: {str(e)}")
|
| 694 |
+
# 回退到简单的字幕
|
| 695 |
+
sub_maker.subs = [text]
|
| 696 |
+
# 使用音频文件的实际长度,如果无法获取,则假设为10秒
|
| 697 |
+
sub_maker.offset = [
|
| 698 |
+
(
|
| 699 |
+
0,
|
| 700 |
+
audio_duration_100ns
|
| 701 |
+
if "audio_duration_100ns" in locals()
|
| 702 |
+
else 10000000,
|
| 703 |
+
)
|
| 704 |
+
]
|
| 705 |
+
|
| 706 |
+
logger.success(f"siliconflow tts succeeded: {voice_file}")
|
| 707 |
+
logger.debug(
|
| 708 |
+
"siliconflow subtitle timeline generated, "
|
| 709 |
+
f"subs: {len(sub_maker.subs)}, offsets: {len(sub_maker.offset)}"
|
| 710 |
+
)
|
| 711 |
+
return sub_maker
|
| 712 |
+
else:
|
| 713 |
+
logger.error(
|
| 714 |
+
f"siliconflow tts failed with status code {response.status_code}: {response.text}"
|
| 715 |
+
)
|
| 716 |
+
except Exception as e:
|
| 717 |
+
logger.error(f"siliconflow tts failed: {str(e)}")
|
| 718 |
+
|
| 719 |
+
return None
|
| 720 |
+
|
| 721 |
+
|
| 722 |
+
def azure_tts_v2(text: str, voice_name: str, voice_file: str) -> Union[SubMaker, None]:
|
| 723 |
+
voice_name = is_azure_v2_voice(voice_name)
|
| 724 |
+
if not voice_name:
|
| 725 |
+
logger.error(f"invalid voice name: {voice_name}")
|
| 726 |
+
raise ValueError(f"invalid voice name: {voice_name}")
|
| 727 |
+
text = text.strip()
|
| 728 |
+
|
| 729 |
+
def _format_duration_to_offset(duration) -> int:
|
| 730 |
+
if isinstance(duration, str):
|
| 731 |
+
time_obj = datetime.strptime(duration, "%H:%M:%S.%f")
|
| 732 |
+
milliseconds = (
|
| 733 |
+
(time_obj.hour * 3600000)
|
| 734 |
+
+ (time_obj.minute * 60000)
|
| 735 |
+
+ (time_obj.second * 1000)
|
| 736 |
+
+ (time_obj.microsecond // 1000)
|
| 737 |
+
)
|
| 738 |
+
return milliseconds * 10000
|
| 739 |
+
|
| 740 |
+
if isinstance(duration, int):
|
| 741 |
+
return duration
|
| 742 |
+
|
| 743 |
+
return 0
|
| 744 |
+
|
| 745 |
+
for i in range(3):
|
| 746 |
+
try:
|
| 747 |
+
logger.info(f"start, voice name: {voice_name}, try: {i + 1}")
|
| 748 |
+
|
| 749 |
+
import azure.cognitiveservices.speech as speechsdk
|
| 750 |
+
|
| 751 |
+
sub_maker = ensure_legacy_submaker_fields(SubMaker())
|
| 752 |
+
|
| 753 |
+
def speech_synthesizer_word_boundary_cb(evt: speechsdk.SessionEventArgs):
|
| 754 |
+
# print('WordBoundary event:')
|
| 755 |
+
# print('\tBoundaryType: {}'.format(evt.boundary_type))
|
| 756 |
+
# print('\tAudioOffset: {}ms'.format((evt.audio_offset + 5000)))
|
| 757 |
+
# print('\tDuration: {}'.format(evt.duration))
|
| 758 |
+
# print('\tText: {}'.format(evt.text))
|
| 759 |
+
# print('\tTextOffset: {}'.format(evt.text_offset))
|
| 760 |
+
# print('\tWordLength: {}'.format(evt.word_length))
|
| 761 |
+
|
| 762 |
+
duration = _format_duration_to_offset(str(evt.duration))
|
| 763 |
+
offset = _format_duration_to_offset(evt.audio_offset)
|
| 764 |
+
sub_maker.subs.append(evt.text)
|
| 765 |
+
sub_maker.offset.append((offset, offset + duration))
|
| 766 |
+
|
| 767 |
+
# Creates an instance of a speech config with specified subscription key and service region.
|
| 768 |
+
speech_key = config.azure.get("speech_key", "")
|
| 769 |
+
service_region = config.azure.get("speech_region", "")
|
| 770 |
+
if not speech_key or not service_region:
|
| 771 |
+
logger.error("Azure speech key or region is not set")
|
| 772 |
+
return None
|
| 773 |
+
|
| 774 |
+
audio_config = speechsdk.audio.AudioOutputConfig(
|
| 775 |
+
filename=voice_file, use_default_speaker=True
|
| 776 |
+
)
|
| 777 |
+
speech_config = speechsdk.SpeechConfig(
|
| 778 |
+
subscription=speech_key, region=service_region
|
| 779 |
+
)
|
| 780 |
+
speech_config.speech_synthesis_voice_name = voice_name
|
| 781 |
+
# speech_config.set_property(property_id=speechsdk.PropertyId.SpeechServiceResponse_RequestSentenceBoundary,
|
| 782 |
+
# value='true')
|
| 783 |
+
speech_config.set_property(
|
| 784 |
+
property_id=speechsdk.PropertyId.SpeechServiceResponse_RequestWordBoundary,
|
| 785 |
+
value="true",
|
| 786 |
+
)
|
| 787 |
+
|
| 788 |
+
speech_config.set_speech_synthesis_output_format(
|
| 789 |
+
speechsdk.SpeechSynthesisOutputFormat.Audio48Khz192KBitRateMonoMp3
|
| 790 |
+
)
|
| 791 |
+
speech_synthesizer = speechsdk.SpeechSynthesizer(
|
| 792 |
+
audio_config=audio_config, speech_config=speech_config
|
| 793 |
+
)
|
| 794 |
+
speech_synthesizer.synthesis_word_boundary.connect(
|
| 795 |
+
speech_synthesizer_word_boundary_cb
|
| 796 |
+
)
|
| 797 |
+
|
| 798 |
+
result = speech_synthesizer.speak_text_async(text).get()
|
| 799 |
+
if result.reason == speechsdk.ResultReason.SynthesizingAudioCompleted:
|
| 800 |
+
logger.success(f"azure v2 speech synthesis succeeded: {voice_file}")
|
| 801 |
+
return sub_maker
|
| 802 |
+
elif result.reason == speechsdk.ResultReason.Canceled:
|
| 803 |
+
cancellation_details = result.cancellation_details
|
| 804 |
+
logger.error(
|
| 805 |
+
f"azure v2 speech synthesis canceled: {cancellation_details.reason}"
|
| 806 |
+
)
|
| 807 |
+
if cancellation_details.reason == speechsdk.CancellationReason.Error:
|
| 808 |
+
logger.error(
|
| 809 |
+
f"azure v2 speech synthesis error: {cancellation_details.error_details}"
|
| 810 |
+
)
|
| 811 |
+
logger.info(f"completed, output file: {voice_file}")
|
| 812 |
+
except Exception as e:
|
| 813 |
+
logger.error(f"failed, error: {str(e)}")
|
| 814 |
+
return None
|
| 815 |
+
|
| 816 |
+
|
| 817 |
+
def gemini_tts(
|
| 818 |
+
text: str,
|
| 819 |
+
voice_name: str,
|
| 820 |
+
voice_rate: float,
|
| 821 |
+
voice_file: str,
|
| 822 |
+
voice_volume: float = 1.0,
|
| 823 |
+
) -> Union[SubMaker, None]:
|
| 824 |
+
"""
|
| 825 |
+
使用Google Gemini TTS生成语音
|
| 826 |
+
|
| 827 |
+
Args:
|
| 828 |
+
text: 要转换的文本
|
| 829 |
+
voice_name: 语音名称,如 "Zephyr", "Puck" 等
|
| 830 |
+
voice_rate: 语音速率(当前未使用)
|
| 831 |
+
voice_file: 输出音频文件路径
|
| 832 |
+
voice_volume: 音频音量(当前未使用)
|
| 833 |
+
|
| 834 |
+
Returns:
|
| 835 |
+
SubMaker对象或None
|
| 836 |
+
"""
|
| 837 |
+
import base64
|
| 838 |
+
import io
|
| 839 |
+
from pydub import AudioSegment
|
| 840 |
+
import google.generativeai as genai
|
| 841 |
+
_configure_pydub_ffmpeg(AudioSegment)
|
| 842 |
+
|
| 843 |
+
try:
|
| 844 |
+
# 配置Gemini API
|
| 845 |
+
api_key = config.app.get("gemini_api_key", "")
|
| 846 |
+
if not api_key:
|
| 847 |
+
logger.error("Gemini API key is not set")
|
| 848 |
+
return None
|
| 849 |
+
|
| 850 |
+
genai.configure(api_key=api_key)
|
| 851 |
+
|
| 852 |
+
logger.info(f"start, voice name: {voice_name}, try: 1")
|
| 853 |
+
|
| 854 |
+
# 使用Gemini TTS API
|
| 855 |
+
model = genai.GenerativeModel("gemini-2.5-flash-preview-tts")
|
| 856 |
+
|
| 857 |
+
generation_config = {
|
| 858 |
+
"response_modalities": ["AUDIO"],
|
| 859 |
+
"speech_config": {
|
| 860 |
+
"voice_config": {
|
| 861 |
+
"prebuilt_voice_config": {
|
| 862 |
+
"voice_name": voice_name
|
| 863 |
+
}
|
| 864 |
+
}
|
| 865 |
+
}
|
| 866 |
+
}
|
| 867 |
+
|
| 868 |
+
response = model.generate_content(
|
| 869 |
+
contents=text,
|
| 870 |
+
generation_config=generation_config
|
| 871 |
+
)
|
| 872 |
+
|
| 873 |
+
# 检查响应
|
| 874 |
+
if not response.candidates or not response.candidates[0].content:
|
| 875 |
+
logger.error("No audio content received from Gemini TTS")
|
| 876 |
+
return None
|
| 877 |
+
|
| 878 |
+
# 获取音频数据
|
| 879 |
+
audio_data = None
|
| 880 |
+
for part in response.candidates[0].content.parts:
|
| 881 |
+
if hasattr(part, 'inline_data') and part.inline_data:
|
| 882 |
+
audio_data = part.inline_data.data
|
| 883 |
+
break
|
| 884 |
+
|
| 885 |
+
if not audio_data:
|
| 886 |
+
logger.error("No audio data found in response")
|
| 887 |
+
return None
|
| 888 |
+
|
| 889 |
+
# 音频数据已经是原始字节,不需要base64解码
|
| 890 |
+
if isinstance(audio_data, str):
|
| 891 |
+
# 如果是字符串,则需要base64解码
|
| 892 |
+
audio_bytes = base64.b64decode(audio_data)
|
| 893 |
+
else:
|
| 894 |
+
# 如果已经是字节,直接使用
|
| 895 |
+
audio_bytes = audio_data
|
| 896 |
+
|
| 897 |
+
# 尝试不同的音频格式 - Gemini可能返回不同的格式
|
| 898 |
+
audio_segment = None
|
| 899 |
+
|
| 900 |
+
# Gemini返回Linear PCM格式,按照文档参数解析
|
| 901 |
+
try:
|
| 902 |
+
audio_segment = AudioSegment.from_file(
|
| 903 |
+
io.BytesIO(audio_bytes),
|
| 904 |
+
format="raw",
|
| 905 |
+
frame_rate=24000, # Gemini TTS默认采样率
|
| 906 |
+
channels=1, # 单声道
|
| 907 |
+
sample_width=2 # 16-bit
|
| 908 |
+
)
|
| 909 |
+
except Exception as e:
|
| 910 |
+
logger.error(f"Failed to load PCM audio: {e}")
|
| 911 |
+
return None
|
| 912 |
+
|
| 913 |
+
# 导出为MP3格式
|
| 914 |
+
audio_segment.export(voice_file, format="mp3")
|
| 915 |
+
|
| 916 |
+
logger.info(f"completed, output file: {voice_file}")
|
| 917 |
+
|
| 918 |
+
# Gemini 拿不到 edge_tts 那种逐词边界事件,因此这里退回到
|
| 919 |
+
# 项目原有的 `subs/offset` 兼容结构,至少保证后续字幕与时长
|
| 920 |
+
# 计算链路可继续工作。
|
| 921 |
+
sub_maker = ensure_legacy_submaker_fields(SubMaker())
|
| 922 |
+
audio_duration = len(audio_segment) / 1000.0 # 转换为秒
|
| 923 |
+
return populate_legacy_submaker_with_full_text(
|
| 924 |
+
sub_maker=sub_maker,
|
| 925 |
+
text=text,
|
| 926 |
+
audio_duration_seconds=audio_duration,
|
| 927 |
+
)
|
| 928 |
+
|
| 929 |
+
except ImportError as e:
|
| 930 |
+
logger.error(f"Missing required package for Gemini TTS: {str(e)}. Please install: pip install pydub")
|
| 931 |
+
return None
|
| 932 |
+
except Exception as e:
|
| 933 |
+
logger.error(f"Gemini TTS failed, error: {str(e)}")
|
| 934 |
+
return None
|
| 935 |
+
|
| 936 |
+
|
| 937 |
+
def mimo_tts(
|
| 938 |
+
text: str,
|
| 939 |
+
voice_name: str,
|
| 940 |
+
voice_rate: float,
|
| 941 |
+
voice_file: str,
|
| 942 |
+
voice_volume: float = 1.0,
|
| 943 |
+
) -> Union[SubMaker, None]:
|
| 944 |
+
"""
|
| 945 |
+
使用 Xiaomi MiMo V2.5 TTS 生成语音。
|
| 946 |
+
|
| 947 |
+
官方接口兼容 OpenAI Chat Completions,但 TTS ���两个关键差异:
|
| 948 |
+
1. 待合成文本必须放在 `assistant` 消息里;
|
| 949 |
+
2. 音频以 `message.audio.data` 的 base64 字符串返回。
|
| 950 |
+
|
| 951 |
+
MiMo 当前没有返回逐词时间轴,因此这里复用项目已有的 legacy
|
| 952 |
+
SubMaker 兜底方案:根据最终音频时长和脚本文本断句生成字幕时间轴。
|
| 953 |
+
"""
|
| 954 |
+
from pydub import AudioSegment
|
| 955 |
+
|
| 956 |
+
text = (text or "").strip()
|
| 957 |
+
if not text:
|
| 958 |
+
logger.error("MiMo TTS text is empty")
|
| 959 |
+
return None
|
| 960 |
+
|
| 961 |
+
api_key = config.app.get("mimo_api_key", "")
|
| 962 |
+
if not api_key:
|
| 963 |
+
logger.error("MiMo API key is not set")
|
| 964 |
+
return None
|
| 965 |
+
|
| 966 |
+
base_url = config.app.get("mimo_base_url", "") or _MIMO_DEFAULT_BASE_URL
|
| 967 |
+
model_name = config.app.get("mimo_tts_model_name", "") or _MIMO_DEFAULT_TTS_MODEL
|
| 968 |
+
style_prompt = config.app.get(
|
| 969 |
+
"mimo_tts_style_prompt",
|
| 970 |
+
"请用自然、清晰、适合短视频旁白的语气朗读。",
|
| 971 |
+
)
|
| 972 |
+
|
| 973 |
+
_configure_pydub_ffmpeg(AudioSegment)
|
| 974 |
+
|
| 975 |
+
for i in range(3):
|
| 976 |
+
try:
|
| 977 |
+
logger.info(
|
| 978 |
+
f"start mimo tts, model: {model_name}, voice: {voice_name}, try: {i + 1}"
|
| 979 |
+
)
|
| 980 |
+
ensure_file_path_exists(voice_file)
|
| 981 |
+
|
| 982 |
+
client = OpenAI(api_key=api_key, base_url=base_url)
|
| 983 |
+
completion = client.chat.completions.create(
|
| 984 |
+
model=model_name,
|
| 985 |
+
messages=[
|
| 986 |
+
{"role": "user", "content": style_prompt},
|
| 987 |
+
{"role": "assistant", "content": text},
|
| 988 |
+
],
|
| 989 |
+
audio={
|
| 990 |
+
"format": "wav",
|
| 991 |
+
"voice": voice_name,
|
| 992 |
+
},
|
| 993 |
+
)
|
| 994 |
+
|
| 995 |
+
if not completion or not getattr(completion, "choices", None):
|
| 996 |
+
raise ValueError("MiMo TTS returned empty response")
|
| 997 |
+
|
| 998 |
+
message = completion.choices[0].message
|
| 999 |
+
audio = getattr(message, "audio", None)
|
| 1000 |
+
audio_data = None
|
| 1001 |
+
if isinstance(audio, dict):
|
| 1002 |
+
audio_data = audio.get("data")
|
| 1003 |
+
elif audio is not None:
|
| 1004 |
+
audio_data = getattr(audio, "data", None)
|
| 1005 |
+
|
| 1006 |
+
if not audio_data:
|
| 1007 |
+
raise ValueError("MiMo TTS returned empty audio data")
|
| 1008 |
+
|
| 1009 |
+
audio_bytes = base64.b64decode(audio_data)
|
| 1010 |
+
audio_segment = AudioSegment.from_file(io.BytesIO(audio_bytes), format="wav")
|
| 1011 |
+
|
| 1012 |
+
output_format = utils.parse_extension(voice_file) or "mp3"
|
| 1013 |
+
if output_format == "wav":
|
| 1014 |
+
with open(voice_file, "wb") as f:
|
| 1015 |
+
f.write(audio_bytes)
|
| 1016 |
+
else:
|
| 1017 |
+
audio_segment.export(voice_file, format=output_format)
|
| 1018 |
+
|
| 1019 |
+
audio_duration = len(audio_segment) / 1000.0
|
| 1020 |
+
sub_maker = ensure_legacy_submaker_fields(SubMaker())
|
| 1021 |
+
logger.success(f"mimo tts succeeded: {voice_file}")
|
| 1022 |
+
logger.debug(
|
| 1023 |
+
"mimo subtitle timeline generated, "
|
| 1024 |
+
f"duration: {audio_duration:.3f}s, output_format: {output_format}"
|
| 1025 |
+
)
|
| 1026 |
+
return populate_legacy_submaker_with_full_text(
|
| 1027 |
+
sub_maker=sub_maker,
|
| 1028 |
+
text=text,
|
| 1029 |
+
audio_duration_seconds=audio_duration,
|
| 1030 |
+
)
|
| 1031 |
+
except Exception as e:
|
| 1032 |
+
logger.error(f"mimo tts failed: {str(e)}")
|
| 1033 |
+
|
| 1034 |
+
return None
|
| 1035 |
+
|
| 1036 |
+
|
| 1037 |
+
def _format_text(text: str) -> str:
|
| 1038 |
+
"""
|
| 1039 |
+
清理字幕对齐前的脚本文本。
|
| 1040 |
+
|
| 1041 |
+
这里不能只在 LLM 生成阶段处理,因为用户也可能手动粘贴脚本,或通过
|
| 1042 |
+
API 直接传入包含 Markdown 标记的文本。TTS 通常不会朗读 `---`、
|
| 1043 |
+
`___`、`***` 这类分隔符行,也不会朗读 `_` 这种强调标记;如果字幕
|
| 1044 |
+
对齐仍保留这些字符,`create_subtitle()` 会一直等待不存在的 cue,
|
| 1045 |
+
最终导致字幕文件缺失并在 Whisper fallback 校正时补出全 0 时间轴。
|
| 1046 |
+
"""
|
| 1047 |
+
text = text.replace("[", " ")
|
| 1048 |
+
text = text.replace("]", " ")
|
| 1049 |
+
text = text.replace("(", " ")
|
| 1050 |
+
text = text.replace(")", " ")
|
| 1051 |
+
text = text.replace("{", " ")
|
| 1052 |
+
text = text.replace("}", " ")
|
| 1053 |
+
return utils.normalize_script_for_subtitle_matching(text)
|
| 1054 |
+
|
| 1055 |
+
|
| 1056 |
+
def _build_subtitle_formatter():
|
| 1057 |
+
"""
|
| 1058 |
+
返回统一的 SRT 行格式化函数。
|
| 1059 |
+
|
| 1060 |
+
这里单独拆成一个小工具,是为了让 edge_tts 7.x 的 cues 路径
|
| 1061 |
+
和项目原有的 legacy `subs/offset` 路径共用同一套字幕落盘格式,
|
| 1062 |
+
避免两套逻辑各自产生细微格式差异。
|
| 1063 |
+
"""
|
| 1064 |
+
|
| 1065 |
+
def formatter(idx: int, start_time: float, end_time: float, sub_text: str) -> str:
|
| 1066 |
+
start_t = mktimestamp(start_time).replace(".", ",")
|
| 1067 |
+
end_t = mktimestamp(end_time).replace(".", ",")
|
| 1068 |
+
return f"{idx}\n{start_t} --> {end_t}\n{sub_text}\n"
|
| 1069 |
+
|
| 1070 |
+
return formatter
|
| 1071 |
+
|
| 1072 |
+
|
| 1073 |
+
# 阿拉伯语变音符号和 Tatweel 拉长符在 edge-tts 返回文本中可能出现,
|
| 1074 |
+
# 这些字符不影响语义,但会导致脚本文本和字幕 cue 字符串精确匹配失败。
|
| 1075 |
+
_ARABIC_DIACRITICS = re.compile("[\u0610-\u061A\u064B-\u065F\u0670\u0640\u06D6-\u06ED]")
|
| 1076 |
+
|
| 1077 |
+
|
| 1078 |
+
def _normalize_arabic(text: str) -> str:
|
| 1079 |
+
"""统一阿拉伯语常见字母变体,提升字幕 cue 与脚本行的匹配容错率。
|
| 1080 |
+
|
| 1081 |
+
edge-tts 对阿拉伯语可能返回与原脚本不同的字母形态,例如把 أ/إ/آ
|
| 1082 |
+
归一成 ا,或者携带变音符号。这里仅在最后一层匹配兜底中使用,
|
| 1083 |
+
不改变原始字幕文本,避免影响最终展示内容。
|
| 1084 |
+
"""
|
| 1085 |
+
text = _ARABIC_DIACRITICS.sub("", text)
|
| 1086 |
+
for src, dst in (
|
| 1087 |
+
("أإآٱ", "ا"),
|
| 1088 |
+
("ىئ", "ي"),
|
| 1089 |
+
("ة", "ه"),
|
| 1090 |
+
("ؤ", "و"),
|
| 1091 |
+
):
|
| 1092 |
+
for ch in src:
|
| 1093 |
+
text = text.replace(ch, dst)
|
| 1094 |
+
return text
|
| 1095 |
+
|
| 1096 |
+
|
| 1097 |
+
def _match_script_line(script_lines: list[str], current_text: str, sub_index: int) -> str:
|
| 1098 |
+
"""
|
| 1099 |
+
尝试把当前累计的字幕文本,与脚本中的某一条标准断句匹配起来。
|
| 1100 |
+
|
| 1101 |
+
这里复用了项目原有的“按标点拆脚本,再逐段比对”的思路:
|
| 1102 |
+
1. 优先精确匹配;
|
| 1103 |
+
2. 再做一次去标点和 Markdown `_` 格式符后的匹配;
|
| 1104 |
+
3. 最后做一次阿拉伯语字符形态归一化匹配。
|
| 1105 |
+
|
| 1106 |
+
这样可以兼容:
|
| 1107 |
+
- TTS 返回里可能缺失或单独拆分的标点;
|
| 1108 |
+
- 中文场景下词边界和脚本文本不完全一一对应的情况。
|
| 1109 |
+
"""
|
| 1110 |
+
if len(script_lines) <= sub_index:
|
| 1111 |
+
return ""
|
| 1112 |
+
|
| 1113 |
+
target_line = script_lines[sub_index]
|
| 1114 |
+
if current_text == target_line:
|
| 1115 |
+
return target_line.strip()
|
| 1116 |
+
|
| 1117 |
+
current_text_normalized = re.sub(r"[_\W]+", "", current_text)
|
| 1118 |
+
target_line_normalized = re.sub(r"[_\W]+", "", target_line)
|
| 1119 |
+
if current_text_normalized == target_line_normalized:
|
| 1120 |
+
return target_line.strip()
|
| 1121 |
+
|
| 1122 |
+
# 最后一层阿拉伯语容错:edge-tts 返回的字母形态、变音符号或 Tatweel
|
| 1123 |
+
# 可能和脚本不同。只在常规匹配失败后归一化比较,非阿拉伯语文本不会受影响。
|
| 1124 |
+
current_ar = re.sub(r"[_\W]+", "", _normalize_arabic(current_text))
|
| 1125 |
+
target_ar = re.sub(r"[_\W]+", "", _normalize_arabic(target_line))
|
| 1126 |
+
if current_ar and current_ar == target_ar:
|
| 1127 |
+
return target_line.strip()
|
| 1128 |
+
|
| 1129 |
+
return ""
|
| 1130 |
+
|
| 1131 |
+
|
| 1132 |
+
def _write_subtitle_items(sub_items: list[str], subtitle_file: str) -> bool:
|
| 1133 |
+
"""
|
| 1134 |
+
将已经聚合好的字幕段写入到 SRT 文件,并做一次基本可读性验证。
|
| 1135 |
+
|
| 1136 |
+
返回值:
|
| 1137 |
+
- `True`:字幕文件成功落盘且可被 moviepy 解析;
|
| 1138 |
+
- `False`:字幕文件写入或解析失败。
|
| 1139 |
+
"""
|
| 1140 |
+
try:
|
| 1141 |
+
ensure_file_path_exists(subtitle_file)
|
| 1142 |
+
with open(subtitle_file, "w", encoding="utf-8") as file:
|
| 1143 |
+
file.write("\n".join(sub_items) + "\n")
|
| 1144 |
+
|
| 1145 |
+
sbs = subtitles.file_to_subtitles(subtitle_file, encoding="utf-8")
|
| 1146 |
+
duration = max([tb for ((ta, tb), txt) in sbs]) if sbs else 0
|
| 1147 |
+
logger.info(
|
| 1148 |
+
f"completed, subtitle file created: {subtitle_file}, duration: {duration}"
|
| 1149 |
+
)
|
| 1150 |
+
return True
|
| 1151 |
+
except Exception as e:
|
| 1152 |
+
logger.error(f"failed, error: {str(e)}")
|
| 1153 |
+
if os.path.exists(subtitle_file):
|
| 1154 |
+
os.remove(subtitle_file)
|
| 1155 |
+
return False
|
| 1156 |
+
|
| 1157 |
+
|
| 1158 |
+
def _build_subtitle_items_from_edge_cues(
|
| 1159 |
+
sub_maker: SubMaker, script_lines: list[str]
|
| 1160 |
+
) -> list[str]:
|
| 1161 |
+
"""
|
| 1162 |
+
将 edge_tts 7.x 的细粒度 `cues` 聚合为按脚本断句的 SRT 片段。
|
| 1163 |
+
|
| 1164 |
+
背景:
|
| 1165 |
+
edge_tts 7.x 的 `SubMaker.get_srt()` 更偏向逐词/逐短语的时间轴。
|
| 1166 |
+
对英文做逐词高亮尚可,但中文短视频字幕如果直接照搬,会出现
|
| 1167 |
+
“金钱 / 是 / 一种 / 社会 / 工具” 这种阅读体验很差的效果。
|
| 1168 |
+
|
| 1169 |
+
实现策略:
|
| 1170 |
+
1. 逐个消费 cues 中的 `content`;
|
| 1171 |
+
2. 累积成一段候选文本;
|
| 1172 |
+
3. 当候选文本与脚本里当前目标断句匹配时,收敛为一个完整字幕段;
|
| 1173 |
+
4. 使用第一条 cue 的开始时间和最后一条 cue 的结束时间,保证时间轴连续。
|
| 1174 |
+
"""
|
| 1175 |
+
formatter = _build_subtitle_formatter()
|
| 1176 |
+
sub_items = []
|
| 1177 |
+
sub_index = 0
|
| 1178 |
+
current_text = ""
|
| 1179 |
+
current_start_time = None
|
| 1180 |
+
|
| 1181 |
+
for cue in sub_maker.cues:
|
| 1182 |
+
cue_text = unescape(cue.content)
|
| 1183 |
+
if current_start_time is None:
|
| 1184 |
+
current_start_time = int(cue.start.total_seconds() * 10000000)
|
| 1185 |
+
|
| 1186 |
+
current_end_time = int(cue.end.total_seconds() * 10000000)
|
| 1187 |
+
current_text += cue_text
|
| 1188 |
+
|
| 1189 |
+
matched_text = _match_script_line(script_lines, current_text, sub_index)
|
| 1190 |
+
if not matched_text:
|
| 1191 |
+
continue
|
| 1192 |
+
|
| 1193 |
+
sub_index += 1
|
| 1194 |
+
sub_items.append(
|
| 1195 |
+
formatter(
|
| 1196 |
+
idx=sub_index,
|
| 1197 |
+
start_time=current_start_time,
|
| 1198 |
+
end_time=current_end_time,
|
| 1199 |
+
sub_text=matched_text,
|
| 1200 |
+
)
|
| 1201 |
+
)
|
| 1202 |
+
current_text = ""
|
| 1203 |
+
current_start_time = None
|
| 1204 |
+
|
| 1205 |
+
if current_text.strip():
|
| 1206 |
+
logger.warning(
|
| 1207 |
+
f"edge cues still have unmatched text after aggregation: {current_text}"
|
| 1208 |
+
)
|
| 1209 |
+
|
| 1210 |
+
return sub_items
|
| 1211 |
+
|
| 1212 |
+
|
| 1213 |
+
def _build_subtitle_items_from_legacy_submaker(
|
| 1214 |
+
sub_maker: SubMaker, script_lines: list[str]
|
| 1215 |
+
) -> list[str]:
|
| 1216 |
+
"""
|
| 1217 |
+
将项目原有 `subs/offset` 结构聚合为按脚本断句的 SRT 片段。
|
| 1218 |
+
|
| 1219 |
+
这部分保留了原来的核心思路,只是拆成独立函数,便于与 edge_tts 7.x
|
| 1220 |
+
的 cues 聚合逻辑共享同一套断句匹配与落盘流程。
|
| 1221 |
+
"""
|
| 1222 |
+
formatter = _build_subtitle_formatter()
|
| 1223 |
+
start_time = -1.0
|
| 1224 |
+
sub_items = []
|
| 1225 |
+
sub_index = 0
|
| 1226 |
+
sub_line = ""
|
| 1227 |
+
|
| 1228 |
+
legacy_offsets = getattr(sub_maker, "offset", [])
|
| 1229 |
+
legacy_subs = getattr(sub_maker, "subs", [])
|
| 1230 |
+
for _, (offset, sub) in enumerate(zip(legacy_offsets, legacy_subs)):
|
| 1231 |
+
current_start_time, current_end_time = offset
|
| 1232 |
+
if start_time < 0:
|
| 1233 |
+
start_time = current_start_time
|
| 1234 |
+
|
| 1235 |
+
sub_line += unescape(sub)
|
| 1236 |
+
matched_text = _match_script_line(script_lines, sub_line, sub_index)
|
| 1237 |
+
if not matched_text:
|
| 1238 |
+
continue
|
| 1239 |
+
|
| 1240 |
+
sub_index += 1
|
| 1241 |
+
sub_items.append(
|
| 1242 |
+
formatter(
|
| 1243 |
+
idx=sub_index,
|
| 1244 |
+
start_time=start_time,
|
| 1245 |
+
end_time=current_end_time,
|
| 1246 |
+
sub_text=matched_text,
|
| 1247 |
+
)
|
| 1248 |
+
)
|
| 1249 |
+
start_time = -1.0
|
| 1250 |
+
sub_line = ""
|
| 1251 |
+
|
| 1252 |
+
if sub_line.strip():
|
| 1253 |
+
logger.warning(
|
| 1254 |
+
f"legacy subtitle items still have unmatched text after aggregation: {sub_line}"
|
| 1255 |
+
)
|
| 1256 |
+
|
| 1257 |
+
return sub_items
|
| 1258 |
+
|
| 1259 |
+
|
| 1260 |
+
def create_subtitle(sub_maker: SubMaker, text: str, subtitle_file: str):
|
| 1261 |
+
"""
|
| 1262 |
+
优化字幕文件
|
| 1263 |
+
1. 将字幕文件按照标点符号分割成多行
|
| 1264 |
+
2. 逐行匹配字幕文件中的文本
|
| 1265 |
+
3. 生成新的字幕文件
|
| 1266 |
+
"""
|
| 1267 |
+
text = _format_text(text)
|
| 1268 |
+
script_lines = utils.split_string_by_punctuations(text)
|
| 1269 |
+
try:
|
| 1270 |
+
if hasattr(sub_maker, "cues") and sub_maker.cues:
|
| 1271 |
+
sub_items = _build_subtitle_items_from_edge_cues(sub_maker, script_lines)
|
| 1272 |
+
else:
|
| 1273 |
+
sub_items = _build_subtitle_items_from_legacy_submaker(
|
| 1274 |
+
sub_maker, script_lines
|
| 1275 |
+
)
|
| 1276 |
+
|
| 1277 |
+
if len(sub_items) != len(script_lines):
|
| 1278 |
+
logger.warning(
|
| 1279 |
+
f"failed, sub_items len: {len(sub_items)}, script_lines len: {len(script_lines)}"
|
| 1280 |
+
)
|
| 1281 |
+
return
|
| 1282 |
+
|
| 1283 |
+
_write_subtitle_items(sub_items, subtitle_file)
|
| 1284 |
+
except Exception as e:
|
| 1285 |
+
logger.error(f"failed, error: {str(e)}")
|
| 1286 |
+
|
| 1287 |
+
|
| 1288 |
+
def _get_audio_duration_from_submaker(sub_maker: SubMaker):
|
| 1289 |
+
"""
|
| 1290 |
+
获取音频时长
|
| 1291 |
+
"""
|
| 1292 |
+
# 优先兼容 edge_tts 7.x 的 cues 结构;
|
| 1293 |
+
# 如果是项目里其他 TTS 手工填充的旧结构,则继续读取 offset。
|
| 1294 |
+
if hasattr(sub_maker, "cues") and sub_maker.cues:
|
| 1295 |
+
return sub_maker.cues[-1].end.total_seconds()
|
| 1296 |
+
|
| 1297 |
+
legacy_offsets = getattr(sub_maker, "offset", [])
|
| 1298 |
+
if not legacy_offsets:
|
| 1299 |
+
return 0.0
|
| 1300 |
+
return legacy_offsets[-1][1] / 10000000
|
| 1301 |
+
|
| 1302 |
+
def _get_audio_duration_from_mp3(mp3_file: str) -> float:
|
| 1303 |
+
"""
|
| 1304 |
+
获取MP3音频时长
|
| 1305 |
+
"""
|
| 1306 |
+
if not os.path.exists(mp3_file):
|
| 1307 |
+
logger.error(f"MP3 file does not exist: {mp3_file}")
|
| 1308 |
+
return 0.0
|
| 1309 |
+
|
| 1310 |
+
try:
|
| 1311 |
+
# Use moviepy to get the duration of the MP3 file
|
| 1312 |
+
with AudioFileClip(mp3_file) as audio:
|
| 1313 |
+
return audio.duration # Duration in seconds
|
| 1314 |
+
except Exception as e:
|
| 1315 |
+
logger.error(f"Failed to get audio duration from MP3: {str(e)}")
|
| 1316 |
+
return 0.0
|
| 1317 |
+
|
| 1318 |
+
def get_audio_duration(target: Union[str, SubMaker]) -> float:
|
| 1319 |
+
"""
|
| 1320 |
+
获取音频时长
|
| 1321 |
+
如果是SubMaker对象,则从SubMaker中获取时长
|
| 1322 |
+
如果是MP3文件,则从MP3文件中获取时长
|
| 1323 |
+
"""
|
| 1324 |
+
if isinstance(target, SubMaker):
|
| 1325 |
+
return _get_audio_duration_from_submaker(target)
|
| 1326 |
+
elif isinstance(target, str) and target.endswith(".mp3"):
|
| 1327 |
+
return _get_audio_duration_from_mp3(target)
|
| 1328 |
+
else:
|
| 1329 |
+
logger.error(f"Invalid target type: {type(target)}")
|
| 1330 |
+
return 0.0
|
| 1331 |
+
|
| 1332 |
+
if __name__ == "__main__":
|
| 1333 |
+
voice_name = "zh-CN-XiaoxiaoMultilingualNeural-V2-Female"
|
| 1334 |
+
voice_name = parse_voice_name(voice_name)
|
| 1335 |
+
voice_name = is_azure_v2_voice(voice_name)
|
| 1336 |
+
print(voice_name)
|
| 1337 |
+
|
| 1338 |
+
voices = get_all_azure_voices()
|
| 1339 |
+
print(len(voices))
|
| 1340 |
+
|
| 1341 |
+
async def _do():
|
| 1342 |
+
temp_dir = utils.storage_dir("temp")
|
| 1343 |
+
|
| 1344 |
+
voice_names = [
|
| 1345 |
+
"zh-CN-XiaoxiaoMultilingualNeural",
|
| 1346 |
+
# 女性
|
| 1347 |
+
"zh-CN-XiaoxiaoNeural",
|
| 1348 |
+
"zh-CN-XiaoyiNeural",
|
| 1349 |
+
# 男性
|
| 1350 |
+
"zh-CN-YunyangNeural",
|
| 1351 |
+
"zh-CN-YunxiNeural",
|
| 1352 |
+
]
|
| 1353 |
+
text = """
|
| 1354 |
+
静夜思是唐代诗人李白创作的一首五言古诗。这首诗描绘了诗人在寂静的夜晚,看到窗前的明月,不禁想起远方的家乡和亲人,表达了他对家乡和亲人的深深思念之情。全诗内容是:“床前明月光,疑是地上霜。举头望明月,低头思故乡。”在这短短的四句诗中,诗人通过“明月”和“思故乡”的意象,巧妙地表达了离乡背井人的孤独��哀愁。首句“床前明月光”设景立意,通过明亮的月光引出诗人的遐想;“疑是地上霜”增添了夜晚的寒冷感,加深了诗人的孤寂之情;“举头望明月”和“低头思故乡”则是情感的升华,展现了诗人内心深处的乡愁和对家的渴望。这首诗简洁明快,情感真挚,是中国古典诗歌中非常著名的一首,也深受后人喜爱和推崇。
|
| 1355 |
+
"""
|
| 1356 |
+
|
| 1357 |
+
text = """
|
| 1358 |
+
What is the meaning of life? This question has puzzled philosophers, scientists, and thinkers of all kinds for centuries. Throughout history, various cultures and individuals have come up with their interpretations and beliefs around the purpose of life. Some say it's to seek happiness and self-fulfillment, while others believe it's about contributing to the welfare of others and making a positive impact in the world. Despite the myriad of perspectives, one thing remains clear: the meaning of life is a deeply personal concept that varies from one person to another. It's an existential inquiry that encourages us to reflect on our values, desires, and the essence of our existence.
|
| 1359 |
+
"""
|
| 1360 |
+
|
| 1361 |
+
text = """
|
| 1362 |
+
预计未来3天深圳冷空气活动频繁,未来两天持续阴天有小雨,出门带好雨具;
|
| 1363 |
+
10-11日持续阴天有小雨,日温差小,气温在13-17℃之间,体感阴凉;
|
| 1364 |
+
12日天气短暂好转,早晚清凉;
|
| 1365 |
+
"""
|
| 1366 |
+
|
| 1367 |
+
text = "[Opening scene: A sunny day in a suburban neighborhood. A young boy named Alex, around 8 years old, is playing in his front yard with his loyal dog, Buddy.]\n\n[Camera zooms in on Alex as he throws a ball for Buddy to fetch. Buddy excitedly runs after it and brings it back to Alex.]\n\nAlex: Good boy, Buddy! You're the best dog ever!\n\n[Buddy barks happily and wags his tail.]\n\n[As Alex and Buddy continue playing, a series of potential dangers loom nearby, such as a stray dog approaching, a ball rolling towards the street, and a suspicious-looking stranger walking by.]\n\nAlex: Uh oh, Buddy, look out!\n\n[Buddy senses the danger and immediately springs into action. He barks loudly at the stray dog, scaring it away. Then, he rushes to retrieve the ball before it reaches the street and gently nudges it back towards Alex. Finally, he stands protectively between Alex and the stranger, growling softly to warn them away.]\n\nAlex: Wow, Buddy, you're like my superhero!\n\n[Just as Alex and Buddy are about to head inside, they hear a loud crash from a nearby construction site. They rush over to investigate and find a pile of rubble blocking the path of a kitten trapped underneath.]\n\nAlex: Oh no, Buddy, we have to help!\n\n[Buddy barks in agreement and together they work to carefully move the rubble aside, allowing the kitten to escape unharmed. The kitten gratefully nuzzles against Buddy, who responds with a friendly lick.]\n\nAlex: We did it, Buddy! We saved the day again!\n\n[As Alex and Buddy walk home together, the sun begins to set, casting a warm glow over the neighborhood.]\n\nAlex: Thanks for always being there to watch over me, Buddy. You're not just my dog, you're my best friend.\n\n[Buddy barks happily and nuzzles against Alex as they disappear into the sunset, ready to face whatever adventures tomorrow may bring.]\n\n[End scene.]"
|
| 1368 |
+
|
| 1369 |
+
text = "大家好,我是乔哥,一个想帮你把信用卡全部还清的家伙!\n今天我们要聊的是信用卡的取现功能。\n你是不是也曾经因为一时的资金紧张,而拿着信用卡到ATM机取现?如果是,那你得好好看看这个视频了。\n现在都2024年了,我以为现在不会再有人用信用卡取现功能了。前几天一个粉丝发来一张图片,取现1万。\n信用卡取现有三个弊端。\n一,信用卡取现功能代价可不小。会先收取一个取现手续费,比如这个粉丝,取现1万,按2.5%收取手续费,收取了250元。\n二,信用卡正常消费有最长56天的免息期,但取现不享受免息期。从取现那一天开始,每天按照万5收取利息,这个粉丝用了11天,收取了55元利息。\n三,频繁的取现行为,银行会认为你资金紧张,会被标记为高风险用户,影响你的综合评分和额度。\n那么,如果你资金紧张了,该怎么办呢?\n乔哥给你支一招,用破思机摩擦信用卡,只需要少量的手续费,而且还可以享受最长56天的免息期。\n最后,如果你对玩卡感兴趣,可以找乔哥领取一本《卡神秘籍》,用卡过程中遇到任何疑惑,也欢迎找乔哥交流。\n别忘了,关注乔哥,回复用卡技巧,免费领取《2024用卡技巧》,让我们一起成为用卡高手!"
|
| 1370 |
+
|
| 1371 |
+
text = """
|
| 1372 |
+
2023全年业绩速览
|
| 1373 |
+
公司全年累计实现营业收入1476.94亿元,同比增长19.01%,归母净利润747.34亿元,同比增长19.16%。EPS达到59.49元。第四季度单季,营业收入444.25亿元,同比增长20.26%,环比增长31.86%;归母净利润218.58亿元,同比增长19.33%,环比增长29.37%。这一阶段
|
| 1374 |
+
的业绩表现不仅突显了公司的增长动力和盈利能力,也反映出公司在竞争激烈的市场环境中保持了良好的发展势头。
|
| 1375 |
+
2023年Q4业绩速览
|
| 1376 |
+
第四季度,营业收入贡献主要增长点;销售费用高增致盈利能力承压;税金同比上升27%,扰动净利率表现。
|
| 1377 |
+
业绩解读
|
| 1378 |
+
利润方面,2023全年贵州茅台,>归母净利润增速为19%,其中营业收入正贡献18%,营业成本正贡献百分之一,管理费用正贡献百分之一点四。(注:归母净利润增速值=营业收入增速+各科目贡献,展示贡献/拖累的前四名科目,且要求贡献值/净利润增速>15%)
|
| 1379 |
+
"""
|
| 1380 |
+
text = "静夜思是唐代诗人李白创作的一首五言古诗。这首诗描绘了诗人在寂静的夜晚,看到窗前的明月,不禁想起远方的家乡和亲人"
|
| 1381 |
+
|
| 1382 |
+
text = _format_text(text)
|
| 1383 |
+
lines = utils.split_string_by_punctuations(text)
|
| 1384 |
+
print(lines)
|
| 1385 |
+
|
| 1386 |
+
for voice_name in voice_names:
|
| 1387 |
+
voice_file = f"{temp_dir}/tts-{voice_name}.mp3"
|
| 1388 |
+
subtitle_file = f"{temp_dir}/tts.mp3.srt"
|
| 1389 |
+
sub_maker = azure_tts_v2(
|
| 1390 |
+
text=text, voice_name=voice_name, voice_file=voice_file
|
| 1391 |
+
)
|
| 1392 |
+
create_subtitle(sub_maker=sub_maker, text=text, subtitle_file=subtitle_file)
|
| 1393 |
+
audio_duration = get_audio_duration(sub_maker)
|
| 1394 |
+
print(f"voice: {voice_name}, audio duration: {audio_duration}s")
|
| 1395 |
+
|
| 1396 |
+
loop = asyncio.get_event_loop_policy().get_event_loop()
|
| 1397 |
+
try:
|
| 1398 |
+
loop.run_until_complete(_do())
|
| 1399 |
+
finally:
|
| 1400 |
+
loop.close()
|
app/utils/file_security.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
def resolve_path_within_directory(
|
| 5 |
+
base_dir: str,
|
| 6 |
+
unsafe_path: str,
|
| 7 |
+
*,
|
| 8 |
+
require_file: bool = True,
|
| 9 |
+
) -> str:
|
| 10 |
+
# 用户传入的路径可能是文件名、相对路径、绝对路径,也可能夹带 `../`。
|
| 11 |
+
# 这里统一解析成真实路径,并用 commonpath 判断它是否仍在允许目录内。
|
| 12 |
+
# 这样比简单判断字符串前缀可靠,可以覆盖符号链接、重复分隔符、相对路径
|
| 13 |
+
# 等场景,适用于上传目录、素材目录、任务产物目录这类白名单目录。
|
| 14 |
+
if not unsafe_path:
|
| 15 |
+
raise ValueError("empty path is not allowed")
|
| 16 |
+
|
| 17 |
+
base_dir_real = os.path.realpath(base_dir)
|
| 18 |
+
candidate_path = unsafe_path
|
| 19 |
+
if not os.path.isabs(candidate_path):
|
| 20 |
+
candidate_path = os.path.join(base_dir_real, candidate_path)
|
| 21 |
+
|
| 22 |
+
resolved_path = os.path.realpath(candidate_path)
|
| 23 |
+
try:
|
| 24 |
+
common_path = os.path.commonpath([base_dir_real, resolved_path])
|
| 25 |
+
except ValueError as exc:
|
| 26 |
+
# Windows 下不同盘符会触发 ValueError,这类路径一定不属于允许目录。
|
| 27 |
+
raise ValueError("path is outside the allowed directory") from exc
|
| 28 |
+
|
| 29 |
+
if common_path != base_dir_real:
|
| 30 |
+
raise ValueError("path is outside the allowed directory")
|
| 31 |
+
|
| 32 |
+
if require_file and not os.path.isfile(resolved_path):
|
| 33 |
+
raise ValueError("file does not exist")
|
| 34 |
+
|
| 35 |
+
return resolved_path
|
app/utils/utils.py
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
import locale
|
| 3 |
+
import os
|
| 4 |
+
import re
|
| 5 |
+
from functools import lru_cache
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
import threading
|
| 8 |
+
from typing import Any
|
| 9 |
+
from uuid import uuid4
|
| 10 |
+
|
| 11 |
+
from loguru import logger
|
| 12 |
+
|
| 13 |
+
from app.models import const
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def get_response(status: int, data: Any = None, message: str = ""):
|
| 17 |
+
obj = {
|
| 18 |
+
"status": status,
|
| 19 |
+
}
|
| 20 |
+
if data:
|
| 21 |
+
obj["data"] = data
|
| 22 |
+
if message:
|
| 23 |
+
obj["message"] = message
|
| 24 |
+
return obj
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def to_json(obj):
|
| 28 |
+
try:
|
| 29 |
+
# Define a helper function to handle different types of objects
|
| 30 |
+
def serialize(o):
|
| 31 |
+
# If the object is a serializable type, return it directly
|
| 32 |
+
if isinstance(o, (int, float, bool, str)) or o is None:
|
| 33 |
+
return o
|
| 34 |
+
# If the object is binary data, convert it to a base64-encoded string
|
| 35 |
+
elif isinstance(o, bytes):
|
| 36 |
+
return "*** binary data ***"
|
| 37 |
+
# If the object is a dictionary, recursively process each key-value pair
|
| 38 |
+
elif isinstance(o, dict):
|
| 39 |
+
return {k: serialize(v) for k, v in o.items()}
|
| 40 |
+
# If the object is a list or tuple, recursively process each element
|
| 41 |
+
elif isinstance(o, (list, tuple)):
|
| 42 |
+
return [serialize(item) for item in o]
|
| 43 |
+
# If the object is a custom type, attempt to return its __dict__ attribute
|
| 44 |
+
elif hasattr(o, "__dict__"):
|
| 45 |
+
return serialize(o.__dict__)
|
| 46 |
+
# Return None for other cases (or choose to raise an exception)
|
| 47 |
+
else:
|
| 48 |
+
return None
|
| 49 |
+
|
| 50 |
+
# Use the serialize function to process the input object
|
| 51 |
+
serialized_obj = serialize(obj)
|
| 52 |
+
|
| 53 |
+
# Serialize the processed object into a JSON string
|
| 54 |
+
return json.dumps(serialized_obj, ensure_ascii=False, indent=4)
|
| 55 |
+
except Exception as e:
|
| 56 |
+
logger.error(f"failed to serialize object to json: {str(e)}")
|
| 57 |
+
return None
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def get_uuid(remove_hyphen: bool = False):
|
| 61 |
+
u = str(uuid4())
|
| 62 |
+
if remove_hyphen:
|
| 63 |
+
u = u.replace("-", "")
|
| 64 |
+
return u
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def root_dir():
|
| 68 |
+
return os.path.dirname(os.path.dirname(os.path.dirname(os.path.realpath(__file__))))
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def storage_dir(sub_dir: str = "", create: bool = False):
|
| 72 |
+
d = os.path.join(root_dir(), "storage")
|
| 73 |
+
if sub_dir:
|
| 74 |
+
d = os.path.join(d, sub_dir)
|
| 75 |
+
if create and not os.path.exists(d):
|
| 76 |
+
os.makedirs(d)
|
| 77 |
+
|
| 78 |
+
return d
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def resource_dir(sub_dir: str = ""):
|
| 82 |
+
d = os.path.join(root_dir(), "resource")
|
| 83 |
+
if sub_dir:
|
| 84 |
+
d = os.path.join(d, sub_dir)
|
| 85 |
+
return d
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def task_dir(sub_dir: str = ""):
|
| 89 |
+
d = os.path.join(storage_dir(), "tasks")
|
| 90 |
+
if sub_dir:
|
| 91 |
+
d = os.path.join(d, sub_dir)
|
| 92 |
+
if not os.path.exists(d):
|
| 93 |
+
os.makedirs(d)
|
| 94 |
+
return d
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def font_dir(sub_dir: str = ""):
|
| 98 |
+
d = resource_dir("fonts")
|
| 99 |
+
if sub_dir:
|
| 100 |
+
d = os.path.join(d, sub_dir)
|
| 101 |
+
if not os.path.exists(d):
|
| 102 |
+
os.makedirs(d)
|
| 103 |
+
return d
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
def song_dir(sub_dir: str = ""):
|
| 107 |
+
d = resource_dir("songs")
|
| 108 |
+
if sub_dir:
|
| 109 |
+
d = os.path.join(d, sub_dir)
|
| 110 |
+
if not os.path.exists(d):
|
| 111 |
+
os.makedirs(d)
|
| 112 |
+
return d
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def public_dir(sub_dir: str = ""):
|
| 116 |
+
d = resource_dir("public")
|
| 117 |
+
if sub_dir:
|
| 118 |
+
d = os.path.join(d, sub_dir)
|
| 119 |
+
if not os.path.exists(d):
|
| 120 |
+
os.makedirs(d)
|
| 121 |
+
return d
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def run_in_background(func, *args, **kwargs):
|
| 125 |
+
def run():
|
| 126 |
+
try:
|
| 127 |
+
func(*args, **kwargs)
|
| 128 |
+
except Exception as e:
|
| 129 |
+
logger.error(f"run_in_background error: {e}", exc_info=True)
|
| 130 |
+
|
| 131 |
+
thread = threading.Thread(target=run, daemon=False)
|
| 132 |
+
thread.start()
|
| 133 |
+
return thread
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
def time_convert_seconds_to_hmsm(seconds) -> str:
|
| 137 |
+
hours = int(seconds // 3600)
|
| 138 |
+
seconds = seconds % 3600
|
| 139 |
+
minutes = int(seconds // 60)
|
| 140 |
+
milliseconds = int(seconds * 1000) % 1000
|
| 141 |
+
seconds = int(seconds % 60)
|
| 142 |
+
return "{:02d}:{:02d}:{:02d},{:03d}".format(hours, minutes, seconds, milliseconds)
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
def text_to_srt(idx: int, msg: str, start_time: float, end_time: float) -> str:
|
| 146 |
+
start_time = time_convert_seconds_to_hmsm(start_time)
|
| 147 |
+
end_time = time_convert_seconds_to_hmsm(end_time)
|
| 148 |
+
srt = """%d
|
| 149 |
+
%s --> %s
|
| 150 |
+
%s
|
| 151 |
+
""" % (
|
| 152 |
+
idx,
|
| 153 |
+
start_time,
|
| 154 |
+
end_time,
|
| 155 |
+
msg,
|
| 156 |
+
)
|
| 157 |
+
return srt
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
def str_contains_punctuation(word):
|
| 161 |
+
for p in const.PUNCTUATIONS:
|
| 162 |
+
if p in word:
|
| 163 |
+
return True
|
| 164 |
+
return False
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
def split_string_by_punctuations(s):
|
| 168 |
+
result = []
|
| 169 |
+
txt = ""
|
| 170 |
+
|
| 171 |
+
previous_char = ""
|
| 172 |
+
next_char = ""
|
| 173 |
+
for i in range(len(s)):
|
| 174 |
+
char = s[i]
|
| 175 |
+
if char == "\n":
|
| 176 |
+
result.append(txt.strip())
|
| 177 |
+
txt = ""
|
| 178 |
+
continue
|
| 179 |
+
|
| 180 |
+
if i > 0:
|
| 181 |
+
previous_char = s[i - 1]
|
| 182 |
+
if i < len(s) - 1:
|
| 183 |
+
next_char = s[i + 1]
|
| 184 |
+
|
| 185 |
+
if char == "." and previous_char.isdigit() and next_char.isdigit():
|
| 186 |
+
# # In the case of "withdraw 10,000, charged at 2.5% fee", the dot in "2.5" should not be treated as a line break marker
|
| 187 |
+
txt += char
|
| 188 |
+
continue
|
| 189 |
+
|
| 190 |
+
if char == "," and previous_char.isdigit() and next_char.isdigit():
|
| 191 |
+
# 英文数字里的千分位逗号不是断句符,例如 "1,000 years"。
|
| 192 |
+
# Edge TTS 的 word boundary 通常会把这种数字整体作为连续内容返回;
|
| 193 |
+
# 如果这里拆成 "1" 和 "000 years",后续字幕聚合会无法匹配脚本原文,
|
| 194 |
+
# 进而错误回退到 Whisper。
|
| 195 |
+
txt += char
|
| 196 |
+
continue
|
| 197 |
+
|
| 198 |
+
if char not in const.PUNCTUATIONS:
|
| 199 |
+
txt += char
|
| 200 |
+
else:
|
| 201 |
+
result.append(txt.strip())
|
| 202 |
+
txt = ""
|
| 203 |
+
result.append(txt.strip())
|
| 204 |
+
# filter empty string
|
| 205 |
+
result = list(filter(None, result))
|
| 206 |
+
return result
|
| 207 |
+
|
| 208 |
+
|
| 209 |
+
def normalize_script_for_subtitle_matching(video_script: str) -> str:
|
| 210 |
+
"""
|
| 211 |
+
清理字幕匹配前的脚本文本。
|
| 212 |
+
|
| 213 |
+
用户可能手动输入 Markdown 分隔符、标题强调或 `_` 这类格式符号。
|
| 214 |
+
这些字符通常不会出现在 TTS/Whisper 的识别结果里;如果继续参与
|
| 215 |
+
字幕逐行匹配,脚本行数量会大于真实字幕行数量,最终可能补出
|
| 216 |
+
`00:00:00,000 --> 00:00:00,000`,导致剪辑软件无法导入 SRT。
|
| 217 |
+
"""
|
| 218 |
+
video_script = video_script or ""
|
| 219 |
+
underscore_count = video_script.count("_")
|
| 220 |
+
video_script = video_script.replace("_", "")
|
| 221 |
+
cleaned_lines = []
|
| 222 |
+
removed_separator_lines = 0
|
| 223 |
+
for line in video_script.splitlines():
|
| 224 |
+
line = line.strip()
|
| 225 |
+
# Markdown 分隔符或强调符号单独成行时不会被 TTS 朗读,必须从
|
| 226 |
+
# 脚本行里移除,避免字幕聚合卡在这类“不可发声”的目标行上。
|
| 227 |
+
if re.fullmatch(r"[-*_]{3,}", line):
|
| 228 |
+
removed_separator_lines += 1
|
| 229 |
+
continue
|
| 230 |
+
cleaned_lines.append(line)
|
| 231 |
+
|
| 232 |
+
normalized_script = "\n".join(cleaned_lines).strip()
|
| 233 |
+
if underscore_count or removed_separator_lines:
|
| 234 |
+
logger.debug(
|
| 235 |
+
"normalized script for subtitle matching, "
|
| 236 |
+
f"removed underscores: {underscore_count}, "
|
| 237 |
+
f"removed markdown separator lines: {removed_separator_lines}"
|
| 238 |
+
)
|
| 239 |
+
return normalized_script
|
| 240 |
+
|
| 241 |
+
|
| 242 |
+
def md5(text):
|
| 243 |
+
import hashlib
|
| 244 |
+
|
| 245 |
+
return hashlib.md5(text.encode("utf-8")).hexdigest()
|
| 246 |
+
|
| 247 |
+
|
| 248 |
+
def get_system_locale():
|
| 249 |
+
try:
|
| 250 |
+
loc = locale.getdefaultlocale()
|
| 251 |
+
# zh_CN, zh_TW return zh
|
| 252 |
+
# en_US, en_GB return en
|
| 253 |
+
language_code = loc[0].split("_")[0]
|
| 254 |
+
return language_code
|
| 255 |
+
except Exception:
|
| 256 |
+
return "en"
|
| 257 |
+
|
| 258 |
+
|
| 259 |
+
@lru_cache(maxsize=None)
|
| 260 |
+
def load_locales(i18n_dir):
|
| 261 |
+
# WebUI 每次交互都会触发 Streamlit 重新执行脚本,语言文件运行期不会变化,
|
| 262 |
+
# 因此缓存解析结果,避免反复读取和解析所有 i18n JSON 文件。
|
| 263 |
+
_locales = {}
|
| 264 |
+
for root, dirs, files in os.walk(i18n_dir):
|
| 265 |
+
for file in files:
|
| 266 |
+
if file.endswith(".json"):
|
| 267 |
+
lang = file.split(".")[0]
|
| 268 |
+
with open(os.path.join(root, file), "r", encoding="utf-8") as f:
|
| 269 |
+
_locales[lang] = json.loads(f.read())
|
| 270 |
+
return _locales
|
| 271 |
+
|
| 272 |
+
|
| 273 |
+
def parse_extension(filename):
|
| 274 |
+
return Path(filename).suffix.lower().lstrip('.')
|
config.example.toml
ADDED
|
@@ -0,0 +1,325 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[app]
|
| 2 |
+
video_source = "pexels" # "pexels" or "pixabay"
|
| 3 |
+
|
| 4 |
+
# 是否隐藏配置面板
|
| 5 |
+
hide_config = false
|
| 6 |
+
|
| 7 |
+
# Azure TTS V1(edge_tts) 单次流式请求超时时间,单位秒。
|
| 8 |
+
# 网络异常、服务端限流或 voice 与文本语言不匹配时,edge_tts 可能长期卡住。
|
| 9 |
+
# 默认 30 秒可避免 WebUI 任务无反馈;慢网络或代理环境可适当调大。
|
| 10 |
+
# 设置为 0 表示禁用超时。
|
| 11 |
+
edge_tts_timeout = 30
|
| 12 |
+
|
| 13 |
+
# 是否校验外部 API 和素材下载的 TLS 证书。
|
| 14 |
+
# 默认必须开启,避免 Pexels/Pixabay API key 和下载素材被中间人攻击篡改。
|
| 15 |
+
# 只有在企业代理或自签证书环境明确需要时,才临时改为 false。
|
| 16 |
+
tls_verify = true
|
| 17 |
+
|
| 18 |
+
# Pexels API Key
|
| 19 |
+
# Register at https://www.pexels.com/api/ to get your API key.
|
| 20 |
+
# You can use multiple keys to avoid rate limits.
|
| 21 |
+
# For example: pexels_api_keys = ["123adsf4567adf89","abd1321cd13efgfdfhi"]
|
| 22 |
+
# 特别注意格式,Key 用英文双引号括起来,多个Key用逗号隔开
|
| 23 |
+
pexels_api_keys = []
|
| 24 |
+
|
| 25 |
+
# Pixabay API Key
|
| 26 |
+
# Register at https://pixabay.com/api/docs/ to get your API key.
|
| 27 |
+
# You can use multiple keys to avoid rate limits.
|
| 28 |
+
# For example: pixabay_api_keys = ["123adsf4567adf89","abd1321cd13efgfdfhi"]
|
| 29 |
+
# 特别注意格式,Key 用英文双引号括起来,多个Key用逗号隔开
|
| 30 |
+
pixabay_api_keys = []
|
| 31 |
+
|
| 32 |
+
# 支持的提供商 (Supported providers):
|
| 33 |
+
# openai
|
| 34 |
+
# aihubmix (OpenAI-compatible AI model gateway)
|
| 35 |
+
# moonshot (月之暗面)
|
| 36 |
+
# azure
|
| 37 |
+
# qwen (通义千问)
|
| 38 |
+
# deepseek
|
| 39 |
+
# gemini
|
| 40 |
+
# ollama
|
| 41 |
+
# g4f (disabled by default; set enable_g4f=true only after accepting its risks)
|
| 42 |
+
# oneapi
|
| 43 |
+
# cloudflare
|
| 44 |
+
# minimax
|
| 45 |
+
# mimo (Xiaomi MiMo)
|
| 46 |
+
# ernie (文心一言)
|
| 47 |
+
# modelscope (魔搭社区)
|
| 48 |
+
# litellm (100+ providers via LiteLLM gateway)
|
| 49 |
+
llm_provider = "openai"
|
| 50 |
+
|
| 51 |
+
########## Pollinations AI Settings
|
| 52 |
+
# Visit https://pollinations.ai/ to learn more
|
| 53 |
+
# API Key is optional - leave empty for public access
|
| 54 |
+
pollinations_api_key = ""
|
| 55 |
+
# Default base URL for Pollinations API
|
| 56 |
+
pollinations_base_url = "https://pollinations.ai/api/v1"
|
| 57 |
+
# Default model for text generation
|
| 58 |
+
pollinations_model_name = "openai-fast"
|
| 59 |
+
|
| 60 |
+
########## Ollama Settings
|
| 61 |
+
# No need to set it unless you want to use your own proxy
|
| 62 |
+
ollama_base_url = ""
|
| 63 |
+
# Check your available models at https://ollama.com/library
|
| 64 |
+
ollama_model_name = ""
|
| 65 |
+
|
| 66 |
+
########## OpenAI API Key
|
| 67 |
+
# Get your API key at https://platform.openai.com/api-keys
|
| 68 |
+
# 也可以填写兼容 OpenAI Chat Completions 协议的供应商密钥,
|
| 69 |
+
# 例如 OpenRouter 这类提供自定义 base_url 的平台。
|
| 70 |
+
openai_api_key = ""
|
| 71 |
+
# No need to set it unless you want to use your own proxy
|
| 72 |
+
# 如果使用兼容 OpenAI 接口的平台,请在这里填写对应的 base_url。
|
| 73 |
+
openai_base_url = ""
|
| 74 |
+
# Check your available models at https://platform.openai.com/account/limits
|
| 75 |
+
# 如果使用兼容供应商,请填写该平台要求的模型 ID。
|
| 76 |
+
openai_model_name = "gpt-4o-mini"
|
| 77 |
+
|
| 78 |
+
########## AIHubMix API Key
|
| 79 |
+
# AIHubMix is OpenAI-compatible and can use one API key to access multiple models.
|
| 80 |
+
# Visit https://aihubmix.com/?aff=CEve to register and get your API key.
|
| 81 |
+
aihubmix_api_key = ""
|
| 82 |
+
aihubmix_base_url = "https://aihubmix.com/v1"
|
| 83 |
+
aihubmix_model_name = "gpt-5.4-mini"
|
| 84 |
+
|
| 85 |
+
########## Moonshot API Key
|
| 86 |
+
# Visit https://platform.moonshot.cn/console/api-keys to get your API key.
|
| 87 |
+
moonshot_api_key = ""
|
| 88 |
+
moonshot_base_url = "https://api.moonshot.cn/v1"
|
| 89 |
+
moonshot_model_name = "moonshot-v1-8k"
|
| 90 |
+
|
| 91 |
+
########## OneAPI API Key
|
| 92 |
+
# Visit https://github.com/songquanpeng/one-api to get your API key
|
| 93 |
+
oneapi_api_key = ""
|
| 94 |
+
oneapi_base_url = ""
|
| 95 |
+
oneapi_model_name = ""
|
| 96 |
+
|
| 97 |
+
########## G4F
|
| 98 |
+
# g4f relies on reverse-engineered third-party endpoints and is not recommended
|
| 99 |
+
# for production. Prefer official APIs, OpenAI-compatible providers, LiteLLM,
|
| 100 |
+
# Ollama, or local inference.
|
| 101 |
+
# If you still need it, install the optional dependency with:
|
| 102 |
+
# uv sync --extra g4f
|
| 103 |
+
enable_g4f = false
|
| 104 |
+
# Visit https://github.com/xtekky/gpt4free to get more details
|
| 105 |
+
# Supported model list: https://github.com/xtekky/gpt4free/blob/main/g4f/models.py
|
| 106 |
+
g4f_model_name = "gpt-3.5-turbo"
|
| 107 |
+
|
| 108 |
+
########## Azure API Key
|
| 109 |
+
# Visit https://learn.microsoft.com/zh-cn/azure/ai-services/openai/ to get more details
|
| 110 |
+
# API documentation: https://learn.microsoft.com/zh-cn/azure/ai-services/openai/reference
|
| 111 |
+
azure_api_key = ""
|
| 112 |
+
azure_base_url = ""
|
| 113 |
+
azure_model_name = "gpt-35-turbo" # replace with your model deployment name
|
| 114 |
+
azure_api_version = "2024-02-15-preview"
|
| 115 |
+
|
| 116 |
+
########## Gemini API Key
|
| 117 |
+
gemini_api_key = ""
|
| 118 |
+
gemini_model_name = "gemini-2.5-flash"
|
| 119 |
+
|
| 120 |
+
########## Grok API Key
|
| 121 |
+
grok_api_key = ""
|
| 122 |
+
grok_model_name = "grok-4.3"
|
| 123 |
+
grok_base_url = "https://api.x.ai/v1"
|
| 124 |
+
|
| 125 |
+
########## Qwen API Key
|
| 126 |
+
# Visit https://dashscope.console.aliyun.com/apiKey to get your API key
|
| 127 |
+
# Visit below links to get more details
|
| 128 |
+
# https://tongyi.aliyun.com/qianwen/
|
| 129 |
+
# https://help.aliyun.com/zh/dashscope/developer-reference/model-introduction
|
| 130 |
+
qwen_api_key = ""
|
| 131 |
+
qwen_model_name = "qwen-max"
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
########## MiniMax API Key
|
| 135 |
+
# Visit https://platform.minimax.io to get your API key
|
| 136 |
+
# MiniMax API is OpenAI-compatible
|
| 137 |
+
# Available models: MiniMax-M2.7 (default), MiniMax-M2.7-highspeed
|
| 138 |
+
minimax_api_key = ""
|
| 139 |
+
minimax_base_url = "https://api.minimax.io/v1"
|
| 140 |
+
minimax_model_name = "MiniMax-M2.7"
|
| 141 |
+
|
| 142 |
+
########## Xiaomi MiMo API Key
|
| 143 |
+
# Xiaomi MiMo API is OpenAI-compatible.
|
| 144 |
+
# Visit https://platform.xiaomimimo.com/docs/zh-CN/quick-start/first-api-call to get more details.
|
| 145 |
+
mimo_api_key = ""
|
| 146 |
+
mimo_base_url = "https://api.xiaomimimo.com/v1"
|
| 147 |
+
mimo_model_name = "mimo-v2.5-pro"
|
| 148 |
+
# Xiaomi MiMo TTS uses the same API key and base_url as MiMo LLM.
|
| 149 |
+
mimo_tts_model_name = "mimo-v2.5-tts"
|
| 150 |
+
# Optional natural language style prompt for MiMo TTS.
|
| 151 |
+
mimo_tts_style_prompt = "请用自然、清晰、适合短视频旁白的语气朗读。"
|
| 152 |
+
|
| 153 |
+
########## DeepSeek API Key
|
| 154 |
+
# Visit https://platform.deepseek.com/api_keys to get your API key
|
| 155 |
+
deepseek_api_key = ""
|
| 156 |
+
deepseek_base_url = "https://api.deepseek.com"
|
| 157 |
+
deepseek_model_name = "deepseek-chat"
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
########## ModelScope API Key
|
| 161 |
+
# Visit https://modelscope.cn/docs/model-service/API-Inference/intro to get your API key
|
| 162 |
+
# And note that you need to bind your Alibaba Cloud account before using the API.
|
| 163 |
+
modelscope_api_key = ""
|
| 164 |
+
modelscope_base_url = "https://api-inference.modelscope.cn/v1/"
|
| 165 |
+
modelscope_model_name = "Qwen/Qwen3-32B"
|
| 166 |
+
|
| 167 |
+
########## LiteLLM (AI Gateway — 100+ providers)
|
| 168 |
+
# LiteLLM routes to any LLM provider via a unified interface.
|
| 169 |
+
# Use this for providers not directly supported above: Anthropic (native API),
|
| 170 |
+
# AWS Bedrock, Google Vertex AI, Cohere, Mistral, Together AI, etc.
|
| 171 |
+
# Visit https://docs.litellm.ai/docs/providers for the full list.
|
| 172 |
+
#
|
| 173 |
+
# LiteLLM reads provider API keys from environment variables automatically:
|
| 174 |
+
# OPENAI_API_KEY, ANTHROPIC_API_KEY, GEMINI_API_KEY, AWS_ACCESS_KEY_ID, etc.
|
| 175 |
+
# Set the appropriate env var for your chosen provider before running.
|
| 176 |
+
#
|
| 177 |
+
# Model name in LiteLLM format: "provider/model-name"
|
| 178 |
+
# Examples: "openai/gpt-4o", "anthropic/claude-sonnet-4-20250514",
|
| 179 |
+
# "bedrock/anthropic.claude-3-5-sonnet-20241022-v2:0",
|
| 180 |
+
# "gemini/gemini-2.5-flash", "ollama/llama3"
|
| 181 |
+
litellm_model_name = "openai/gpt-4o-mini"
|
| 182 |
+
|
| 183 |
+
# Subtitle Provider, "edge" or "whisper"
|
| 184 |
+
# If empty, the subtitle will not be generated
|
| 185 |
+
subtitle_provider = "edge"
|
| 186 |
+
|
| 187 |
+
#
|
| 188 |
+
# ImageMagick
|
| 189 |
+
#
|
| 190 |
+
# Once you have installed it, ImageMagick will be automatically detected, except on Windows!
|
| 191 |
+
# On Windows, for example "C:\Program Files (x86)\ImageMagick-7.1.1-Q16-HDRI\magick.exe"
|
| 192 |
+
# Download from https://imagemagick.org/archive/binaries/ImageMagick-7.1.1-29-Q16-x64-static.exe
|
| 193 |
+
|
| 194 |
+
# imagemagick_path = "C:\\Program Files (x86)\\ImageMagick-7.1.1-Q16\\magick.exe"
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
#
|
| 198 |
+
# FFMPEG
|
| 199 |
+
#
|
| 200 |
+
# 通常情况下,ffmpeg 会被自动下载,并且会被自动检测到。
|
| 201 |
+
# 但是如果你的环境有问题,无法自动下载,可能会遇到如下错误:
|
| 202 |
+
# RuntimeError: No ffmpeg exe could be found.
|
| 203 |
+
# Install ffmpeg on your system, or set the IMAGEIO_FFMPEG_EXE environment variable.
|
| 204 |
+
# 此时你可以手动下载 ffmpeg 并设置 ffmpeg_path,下载地址:https://www.gyan.dev/ffmpeg/builds/
|
| 205 |
+
|
| 206 |
+
# Under normal circumstances, ffmpeg is downloaded automatically and detected automatically.
|
| 207 |
+
# However, if there is an issue with your environment that prevents automatic downloading, you might encounter the following error:
|
| 208 |
+
# RuntimeError: No ffmpeg exe could be found.
|
| 209 |
+
# Install ffmpeg on your system, or set the IMAGEIO_FFMPEG_EXE environment variable.
|
| 210 |
+
# In such cases, you can manually download ffmpeg and set the ffmpeg_path, download link: https://www.gyan.dev/ffmpeg/builds/
|
| 211 |
+
|
| 212 |
+
# ffmpeg_path = "C:\\Users\\harry\\Downloads\\ffmpeg.exe"
|
| 213 |
+
#########################################################################################
|
| 214 |
+
|
| 215 |
+
# 当视频生成成功后,API服务提供的视频下载接入点,默认为当前服务的地址和监听端口
|
| 216 |
+
# 比如 http://127.0.0.1:8080/tasks/6357f542-a4e1-46a1-b4c9-bf3bd0df5285/final-1.mp4
|
| 217 |
+
# 如果你需要使用域名对外提供服务(一般会用nginx做代理),则可以设置为你的域名
|
| 218 |
+
# 比如 https://xxxx.com/tasks/6357f542-a4e1-46a1-b4c9-bf3bd0df5285/final-1.mp4
|
| 219 |
+
# endpoint="https://xxxx.com"
|
| 220 |
+
|
| 221 |
+
# When the video is successfully generated, the API service provides a download endpoint for the video, defaulting to the service's current address and listening port.
|
| 222 |
+
# For example, http://127.0.0.1:8080/tasks/6357f542-a4e1-46a1-b4c9-bf3bd0df5285/final-1.mp4
|
| 223 |
+
# If you need to provide the service externally using a domain name (usually done with nginx as a proxy), you can set it to your domain name.
|
| 224 |
+
# For example, https://xxxx.com/tasks/6357f542-a4e1-46a1-b4c9-bf3bd0df5285/final-1.mp4
|
| 225 |
+
# endpoint="https://xxxx.com"
|
| 226 |
+
endpoint = ""
|
| 227 |
+
|
| 228 |
+
|
| 229 |
+
# Video material storage location
|
| 230 |
+
# material_directory = "" # Indicates that video materials will be downloaded to the default folder, the default folder is ./storage/cache_videos under the current project
|
| 231 |
+
# material_directory = "/user/harry/videos" # Indicates that video materials will be downloaded to a specified folder
|
| 232 |
+
# material_directory = "task" # Indicates that video materials will be downloaded to the current task's folder, this method does not allow sharing of already downloaded video materials
|
| 233 |
+
|
| 234 |
+
# 视频素材存放位置
|
| 235 |
+
# material_directory = "" #表示将视频素材下载到默认的文件夹,默认文件夹为当前项目下的 ./storage/cache_videos
|
| 236 |
+
# material_directory = "/user/harry/videos" #表示将视频素材下载到指定的文件夹中
|
| 237 |
+
# material_directory = "task" #表示将视频素材下载到当前任务的文件夹中,这种方式无法共享已经下载的视频素材
|
| 238 |
+
|
| 239 |
+
material_directory = ""
|
| 240 |
+
|
| 241 |
+
# Used for state management of the task
|
| 242 |
+
enable_redis = false
|
| 243 |
+
redis_host = "localhost"
|
| 244 |
+
redis_port = 6379
|
| 245 |
+
redis_db = 0
|
| 246 |
+
redis_password = ""
|
| 247 |
+
|
| 248 |
+
# 文生视频时的最大并发任务数
|
| 249 |
+
max_concurrent_tasks = 5
|
| 250 |
+
|
| 251 |
+
# 并发任务已满后的最大排队任务数,超过后接口返回 429,避免匿名请求无限堆积
|
| 252 |
+
max_queued_tasks = 100
|
| 253 |
+
|
| 254 |
+
|
| 255 |
+
[whisper]
|
| 256 |
+
# Only effective when subtitle_provider is "whisper"
|
| 257 |
+
|
| 258 |
+
# Run on GPU with FP16
|
| 259 |
+
# model = WhisperModel(model_size, device="cuda", compute_type="float16")
|
| 260 |
+
|
| 261 |
+
# Run on GPU with INT8
|
| 262 |
+
# model = WhisperModel(model_size, device="cuda", compute_type="int8_float16")
|
| 263 |
+
|
| 264 |
+
# Run on CPU with INT8
|
| 265 |
+
# model = WhisperModel(model_size, device="cpu", compute_type="int8")
|
| 266 |
+
|
| 267 |
+
# recommended model_size: "large-v3"
|
| 268 |
+
model_size = "large-v3"
|
| 269 |
+
# if you want to use GPU, set device="cuda"
|
| 270 |
+
device = "CPU"
|
| 271 |
+
compute_type = "int8"
|
| 272 |
+
|
| 273 |
+
|
| 274 |
+
[proxy]
|
| 275 |
+
### Use a proxy to access the Pexels API
|
| 276 |
+
### Format: "http://<username>:<password>@<proxy>:<port>"
|
| 277 |
+
### Example: "http://user:pass@proxy:1234"
|
| 278 |
+
### Doc: https://requests.readthedocs.io/en/latest/user/advanced/#proxies
|
| 279 |
+
|
| 280 |
+
# http = "http://10.10.1.10:3128"
|
| 281 |
+
# https = "http://10.10.1.10:1080"
|
| 282 |
+
|
| 283 |
+
[azure]
|
| 284 |
+
# Azure Speech API Key
|
| 285 |
+
# Get your API key at https://portal.azure.com/#view/Microsoft_Azure_ProjectOxford/CognitiveServicesHub/~/SpeechServices
|
| 286 |
+
speech_key = ""
|
| 287 |
+
speech_region = ""
|
| 288 |
+
|
| 289 |
+
[siliconflow]
|
| 290 |
+
# SiliconFlow API Key
|
| 291 |
+
# Get your API key at https://siliconflow.cn
|
| 292 |
+
api_key = ""
|
| 293 |
+
|
| 294 |
+
[ui]
|
| 295 |
+
# UI related settings
|
| 296 |
+
# 是否隐藏日志信息
|
| 297 |
+
# Whether to hide logs in the UI
|
| 298 |
+
hide_log = false
|
| 299 |
+
|
| 300 |
+
# 字幕位置设置 (Subtitle position settings)
|
| 301 |
+
# 可选值 (Available values): "top", "center", "bottom", "custom"
|
| 302 |
+
# subtitle_position = "custom"
|
| 303 |
+
# 自定义位置,表示离顶部的百分比 (0-100),仅当 subtitle_position = "custom" 时生效
|
| 304 |
+
# Custom position as percentage from top (0-100), only effective when subtitle_position = "custom"
|
| 305 |
+
# custom_position = 70.0
|
| 306 |
+
|
| 307 |
+
########## Upload-Post (Cross-post to TikTok/Instagram)
|
| 308 |
+
# Upload-Post allows you to automatically cross-post generated videos to TikTok and Instagram.
|
| 309 |
+
# Visit https://upload-post.com to create an account and get your API key.
|
| 310 |
+
# API Documentation: https://docs.upload-post.com
|
| 311 |
+
|
| 312 |
+
# Enable/disable Upload-Post integration
|
| 313 |
+
upload_post_enabled = false
|
| 314 |
+
|
| 315 |
+
# Your Upload-Post API key
|
| 316 |
+
upload_post_api_key = ""
|
| 317 |
+
|
| 318 |
+
# Your Upload-Post username
|
| 319 |
+
upload_post_username = ""
|
| 320 |
+
|
| 321 |
+
# Platforms to cross-post to (options: "tiktok", "instagram")
|
| 322 |
+
upload_post_platforms = ["tiktok", "instagram"]
|
| 323 |
+
|
| 324 |
+
# Automatically cross-post videos after generation (if false, you'll need to call the API manually)
|
| 325 |
+
upload_post_auto_upload = false
|
docker-compose.gpu.yml
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# GPU override file
|
| 2 |
+
# Usage: docker compose -f docker-compose.yml -f docker-compose.gpu.yml up -d
|
| 3 |
+
#
|
| 4 |
+
# Prerequisites:
|
| 5 |
+
# 1. NVIDIA GPU driver installed on host
|
| 6 |
+
# 2. NVIDIA Container Toolkit installed
|
| 7 |
+
# 3. Verify with: docker info | grep nvidia
|
| 8 |
+
#
|
| 9 |
+
# This file overrides the default docker-compose.yml to:
|
| 10 |
+
# - Build with Dockerfile.gpu (NVIDIA CUDA base image)
|
| 11 |
+
# - Attach GPU device to the api service
|
| 12 |
+
services:
|
| 13 |
+
webui:
|
| 14 |
+
build:
|
| 15 |
+
context: .
|
| 16 |
+
dockerfile: Dockerfile.gpu
|
| 17 |
+
api:
|
| 18 |
+
build:
|
| 19 |
+
context: .
|
| 20 |
+
dockerfile: Dockerfile.gpu
|
| 21 |
+
deploy:
|
| 22 |
+
resources:
|
| 23 |
+
reservations:
|
| 24 |
+
devices:
|
| 25 |
+
- driver: nvidia
|
| 26 |
+
count: 1
|
| 27 |
+
capabilities: [gpu]
|
docker-compose.yml
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
x-common-volumes: &common-volumes
|
| 2 |
+
- ./:/MoneyPrinterTurbo
|
| 3 |
+
|
| 4 |
+
services:
|
| 5 |
+
webui:
|
| 6 |
+
build:
|
| 7 |
+
context: .
|
| 8 |
+
dockerfile: Dockerfile
|
| 9 |
+
container_name: "moneyprinterturbo-webui"
|
| 10 |
+
ports:
|
| 11 |
+
- "8501:8501"
|
| 12 |
+
command: [ "streamlit", "run", "./webui/Main.py","--browser.serverAddress=127.0.0.1","--server.enableCORS=True","--browser.gatherUsageStats=False" ]
|
| 13 |
+
volumes: *common-volumes
|
| 14 |
+
restart: always
|
| 15 |
+
api:
|
| 16 |
+
build:
|
| 17 |
+
context: .
|
| 18 |
+
dockerfile: Dockerfile
|
| 19 |
+
container_name: "moneyprinterturbo-api"
|
| 20 |
+
ports:
|
| 21 |
+
- "8080:8080"
|
| 22 |
+
command: [ "python3", "main.py" ]
|
| 23 |
+
volumes: *common-volumes
|
| 24 |
+
restart: always
|
main.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import uvicorn
|
| 2 |
+
from loguru import logger
|
| 3 |
+
|
| 4 |
+
from app.config import config
|
| 5 |
+
|
| 6 |
+
if __name__ == "__main__":
|
| 7 |
+
logger.info(
|
| 8 |
+
"start server, docs: http://127.0.0.1:" + str(config.listen_port) + "/docs"
|
| 9 |
+
)
|
| 10 |
+
uvicorn.run(
|
| 11 |
+
app="app.asgi:app",
|
| 12 |
+
host=config.listen_host,
|
| 13 |
+
port=config.listen_port,
|
| 14 |
+
reload=config.reload_debug,
|
| 15 |
+
log_level="warning",
|
| 16 |
+
)
|
pyproject.toml
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 说明:
|
| 2 |
+
# 1. 主依赖来源统一收敛到 pyproject.toml。
|
| 3 |
+
# 2. 运行环境通过 uv.lock 固定,避免不同机器解析出不同版本组合。
|
| 4 |
+
[build-system]
|
| 5 |
+
requires = ["hatchling>=1.27.0"]
|
| 6 |
+
build-backend = "hatchling.build"
|
| 7 |
+
|
| 8 |
+
[project]
|
| 9 |
+
name = "moneyprinterturbo"
|
| 10 |
+
version = "1.2.9"
|
| 11 |
+
description = "Generate short videos from prompts, local assets, subtitles, and TTS."
|
| 12 |
+
readme = "README-en.md"
|
| 13 |
+
requires-python = ">=3.11,<3.13"
|
| 14 |
+
license = { text = "MIT" }
|
| 15 |
+
dependencies = [
|
| 16 |
+
"moviepy==2.1.2",
|
| 17 |
+
"streamlit==1.45.0",
|
| 18 |
+
"edge-tts==7.2.7",
|
| 19 |
+
"fastapi==0.115.6",
|
| 20 |
+
"uvicorn==0.32.1",
|
| 21 |
+
"openai==1.56.1",
|
| 22 |
+
"faster-whisper==1.1.0",
|
| 23 |
+
"loguru==0.7.3",
|
| 24 |
+
"google-generativeai==0.8.6",
|
| 25 |
+
"dashscope==1.20.14",
|
| 26 |
+
"azure-cognitiveservices-speech==1.41.1",
|
| 27 |
+
"redis==5.2.0",
|
| 28 |
+
"python-multipart==0.0.19",
|
| 29 |
+
"pyyaml==6.0.3",
|
| 30 |
+
"requests==2.33.1",
|
| 31 |
+
"socksio==1.0.0",
|
| 32 |
+
"pydub==0.25.1",
|
| 33 |
+
"litellm==1.60.0",
|
| 34 |
+
]
|
| 35 |
+
|
| 36 |
+
[project.optional-dependencies]
|
| 37 |
+
g4f = [
|
| 38 |
+
"g4f==0.5.2.2",
|
| 39 |
+
]
|
| 40 |
+
|
| 41 |
+
[tool.hatch.build.targets.wheel]
|
| 42 |
+
packages = ["app"]
|
| 43 |
+
|
| 44 |
+
[tool.uv]
|
| 45 |
+
# 当前项目以应用运行形态为主,不作为可发布的 Python 包安装。
|
| 46 |
+
package = false
|
requirements.txt
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Keep legacy pip install support; primary deps moved to pyproject.toml / uv.lock.
|
| 2 |
+
moviepy==2.1.2
|
| 3 |
+
streamlit==1.45.0
|
| 4 |
+
edge_tts==7.2.7
|
| 5 |
+
fastapi==0.115.6
|
| 6 |
+
uvicorn==0.32.1
|
| 7 |
+
openai==1.56.1
|
| 8 |
+
faster-whisper==1.1.0
|
| 9 |
+
loguru==0.7.3
|
| 10 |
+
google.generativeai==0.8.6
|
| 11 |
+
dashscope==1.20.14
|
| 12 |
+
g4f==0.5.2.2
|
| 13 |
+
azure-cognitiveservices-speech==1.41.1
|
| 14 |
+
redis==5.2.0
|
| 15 |
+
python-multipart==0.0.19
|
| 16 |
+
pyyaml==6.0.3
|
| 17 |
+
requests==2.33.1
|
| 18 |
+
socksio==1.0.0
|
| 19 |
+
pydub==0.25.1
|
| 20 |
+
litellm==1.60.0
|
resource/public/assets/index-CRiTZ9sN.css
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
@import "https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Outfit:wght@400;500;600;700&display=swap";:root{--bg-primary:#0f111a;--bg-secondary:#1a1d2d;--text-primary:#fff;--text-secondary:#94a3b8;--accent-primary:#6366f1;--accent-hover:#4f46e5;--success:#10b981;--warning:#f59e0b;--danger:#ef4444;--glass-bg:#1a1d2db3;--glass-border:#ffffff1a;--glass-shadow:0 8px 32px 0 #0000005e}*{box-sizing:border-box;margin:0;padding:0}body{background-color:var(--bg-primary);color:var(--text-primary);-webkit-font-smoothing:antialiased;background-image:radial-gradient(circle at 15%,#6366f126 0%,#0000 50%),radial-gradient(circle at 85% 30%,#10b9811a 0%,#0000 50%);background-attachment:fixed;min-height:100vh;font-family:Inter,sans-serif;line-height:1.5}h1,h2,h3,h4,h5,h6{letter-spacing:-.02em;font-family:Outfit,sans-serif;font-weight:600}.app-container{flex-direction:column;gap:2rem;max-width:1280px;margin:0 auto;padding:2rem;animation:.8s ease-out fadeIn;display:flex}.header{border-bottom:1px solid var(--glass-border);justify-content:space-between;align-items:center;padding-bottom:1rem;display:flex}.header h1{background:linear-gradient(135deg,#fff,#94a3b8);-webkit-text-fill-color:transparent;-webkit-background-clip:text;font-size:2.5rem}.header .status{color:var(--text-secondary);align-items:center;gap:.5rem;font-size:.875rem;display:flex}.status-indicator{background-color:var(--success);width:10px;height:10px;box-shadow:0 0 10px var(--success);border-radius:50%;animation:2s infinite pulse}.glass-panel{background:var(--glass-bg);-webkit-backdrop-filter:blur(12px);border:1px solid var(--glass-border);box-shadow:var(--glass-shadow);border-radius:16px;padding:1.5rem;transition:transform .3s,box-shadow .3s}.glass-panel:hover{transform:translateY(-2px);box-shadow:0 12px 40px #00000073}.dashboard-grid{grid-template-columns:1fr 1fr;gap:2rem;display:grid}@media (width<=900px){.dashboard-grid{grid-template-columns:1fr}}.form-group{margin-bottom:1.25rem}.form-group label{color:var(--text-secondary);margin-bottom:.5rem;font-size:.875rem;font-weight:500;display:block}.form-control{color:#fff;background:#0003;border:1px solid #ffffff1a;border-radius:8px;width:100%;padding:.75rem 1rem;font-family:inherit;font-size:1rem;transition:all .2s}.form-control:focus{border-color:var(--accent-primary);background:#0006;outline:none;box-shadow:0 0 0 3px #6366f140}.btn{cursor:pointer;border:none;border-radius:8px;justify-content:center;align-items:center;gap:.5rem;padding:.75rem 1.5rem;font-family:inherit;font-weight:600;transition:all .2s;display:inline-flex}.btn-primary{background:var(--accent-primary);color:#fff;box-shadow:0 4px 14px #6366f163}.btn-primary:hover{background:var(--accent-hover);transform:translateY(-1px);box-shadow:0 6px 20px #6366f13b}.btn-primary:active{transform:translateY(1px)}.btn-primary:disabled{cursor:not-allowed;box-shadow:none;background:#475569;transform:none}.logs-console{background:#000;border:1px solid #ffffff0d;border-radius:8px;height:300px;padding:1rem;font-family:Consolas,Courier New,monospace;font-size:.875rem;overflow-y:auto}.log-entry{margin-bottom:.25rem;line-height:1.4}.log-time{color:#64748b;margin-right:.5rem}.log-info{color:#38bdf8}.log-success{color:#34d399}.log-error{color:#f87171}.log-warning{color:#fbbf24}@keyframes fadeIn{0%{opacity:0;transform:translateY(10px)}to{opacity:1;transform:translateY(0)}}@keyframes pulse{0%{box-shadow:0 0 #10b98166}70%{box-shadow:0 0 0 6px #10b98100}to{box-shadow:0 0 #10b98100}}@keyframes spin{0%{transform:rotate(0)}to{transform:rotate(360deg)}}.spinner{border:2px solid #ffffff4d;border-top-color:#fff;border-radius:50%;width:1rem;height:1rem;animation:.8s linear infinite spin}
|