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 files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .dockerignore +24 -0
  2. .github/ISSUE_TEMPLATE/bug_report.yml +87 -0
  3. .github/ISSUE_TEMPLATE/config.yml +1 -0
  4. .github/ISSUE_TEMPLATE/feature_request.yml +29 -0
  5. .github/SECURITY.md +28 -0
  6. .gitignore +36 -0
  7. .python-version +1 -0
  8. Dockerfile +44 -0
  9. Dockerfile.gpu +55 -0
  10. LICENSE +21 -0
  11. README-ar.md +416 -0
  12. README-en.md +433 -0
  13. README.md +414 -7
  14. app/__init__.py +0 -0
  15. app/asgi.py +82 -0
  16. app/config/__init__.py +56 -0
  17. app/config/config.py +207 -0
  18. app/controllers/base.py +31 -0
  19. app/controllers/manager/base_manager.py +87 -0
  20. app/controllers/manager/memory_manager.py +21 -0
  21. app/controllers/manager/redis_manager.py +64 -0
  22. app/controllers/ping.py +13 -0
  23. app/controllers/v1/base.py +11 -0
  24. app/controllers/v1/llm.py +47 -0
  25. app/controllers/v1/video.py +400 -0
  26. app/models/__init__.py +0 -0
  27. app/models/const.py +30 -0
  28. app/models/exception.py +28 -0
  29. app/models/schema.py +344 -0
  30. app/router.py +17 -0
  31. app/services/__init__.py +0 -0
  32. app/services/data/azure_voices.json +1326 -0
  33. app/services/llm.py +725 -0
  34. app/services/material.py +388 -0
  35. app/services/state.py +168 -0
  36. app/services/subtitle.py +306 -0
  37. app/services/task.py +397 -0
  38. app/services/upload_post.py +147 -0
  39. app/services/utils/video_effects.py +73 -0
  40. app/services/video.py +928 -0
  41. app/services/voice.py +1400 -0
  42. app/utils/file_security.py +35 -0
  43. app/utils/utils.py +274 -0
  44. config.example.toml +325 -0
  45. docker-compose.gpu.yml +27 -0
  46. docker-compose.yml +24 -0
  47. main.py +16 -0
  48. pyproject.toml +46 -0
  49. requirements.txt +20 -0
  50. 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
+ ![](docs/webui-en.jpg)
23
+
24
+ ### واجهة الـ API
25
+
26
+ ![](docs/api.jpg)
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
+ [![Open in Colab](https://colab.research.google.com/assets/colab-badge.svg)](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
+ ![](docs/reccloud.com.jpg)
269
+
270
+ ## شكراً للرعاية 🙏
271
+
272
+ شكراً لـ Picwish https://picwish.com على دعمها ورعايتها لهذا المشروع، مما يتيح التحديث والصيانة المستمرّين.
273
+
274
+ تركّز Picwish على **مجال معالجة الصور**، وتوفّر مجموعة غنية من **أدوات معالجة الصور** التي تبسّط العمليات المعقّدة إلى حدٍّ بعيد، فتجعل معالجة الصور أسهل حقاً.
275
+
276
+ ![picwish.jpg](docs/picwish.com.jpg)
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
+ [![Star History Chart](https://api.star-history.com/svg?repos=harry0703/MoneyPrinterTurbo&type=Date)](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
+ ![](docs/webui-en.jpg)
23
+
24
+ ### API Interface
25
+
26
+ ![](docs/api.jpg)
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
+ [![Open in Colab](https://colab.research.google.com/assets/colab-badge.svg)](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
+ ![](docs/reccloud.com.jpg)
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
+ ![picwish.jpg](docs/picwish.com.jpg)
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
+ [![Star History Chart](https://api.star-history.com/svg?repos=harry0703/MoneyPrinterTurbo&type=Date)](https://star-history.com/#harry0703/MoneyPrinterTurbo&Date)
README.md CHANGED
@@ -1,12 +1,419 @@
1
  ---
2
- title: Money Printer Studio
3
- emoji: 👀
4
  colorFrom: indigo
5
- colorTo: indigo
6
  sdk: docker
7
- pinned: false
8
- license: mit
9
- short_description: money printer turbo (Short Form Video)
10
  ---
11
 
12
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ ![](docs/webui.jpg)
38
+
39
+ <h4>API界面</h4>
40
+
41
+ ![](docs/api.jpg)
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
+ [![Open in Colab](https://colab.research.google.com/assets/colab-badge.svg)](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
+ ![](docs/reccloud.cn.jpg)
287
+
288
+ ## 感谢赞助 🙏
289
+
290
+ 感谢佐糖 https://picwish.cn 对该项目的支持和赞助,使得该项目能够持续的更新和维护。
291
+
292
+ 佐糖专注于**图像处理领域**,提供丰富的**图像处理工具**,将复杂操作极致简化,真正实现让图像处理更简单。
293
+
294
+ ![picwish.jpg](docs/picwish.jpg)
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
+ [![Star History Chart](https://api.star-history.com/svg?repos=harry0703/MoneyPrinterTurbo&type=Date)](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}