├── .dockerignore ├── .github └── ISSUE_TEMPLATE │ ├── bug_report.yml │ ├── config.yml │ └── feature_request.yml ├── .gitignore ├── Dockerfile ├── LICENSE ├── README-en.md ├── README.md ├── app ├── __init__.py ├── asgi.py ├── config │ ├── __init__.py │ └── config.py ├── controllers │ ├── base.py │ ├── manager │ │ ├── base_manager.py │ │ ├── memory_manager.py │ │ └── redis_manager.py │ ├── ping.py │ └── v1 │ │ ├── base.py │ │ ├── llm.py │ │ └── video.py ├── models │ ├── __init__.py │ ├── const.py │ ├── exception.py │ └── schema.py ├── router.py ├── services │ ├── __init__.py │ ├── llm.py │ ├── material.py │ ├── state.py │ ├── subtitle.py │ ├── task.py │ ├── utils │ │ └── video_effects.py │ ├── video.py │ └── voice.py └── utils │ └── utils.py ├── config.example.toml ├── docker-compose.yml ├── docs ├── MoneyPrinterTurbo.ipynb ├── api.jpg ├── picwish.com.jpg ├── picwish.jpg ├── reccloud.cn.jpg ├── reccloud.com.jpg ├── voice-list.txt ├── webui-en.jpg └── webui.jpg ├── main.py ├── requirements.txt ├── resource ├── fonts │ ├── Charm-Bold.ttf │ ├── Charm-Regular.ttf │ ├── MicrosoftYaHeiBold.ttc │ ├── MicrosoftYaHeiNormal.ttc │ ├── STHeitiLight.ttc │ ├── STHeitiMedium.ttc │ └── UTM Kabel KT.ttf ├── public │ └── index.html └── songs │ ├── output000.mp3 │ ├── output001.mp3 │ ├── output002.mp3 │ ├── output003.mp3 │ ├── output004.mp3 │ ├── output005.mp3 │ ├── output006.mp3 │ ├── output007.mp3 │ ├── output008.mp3 │ ├── output009.mp3 │ ├── output010.mp3 │ ├── output011.mp3 │ ├── output012.mp3 │ ├── output013.mp3 │ ├── output014.mp3 │ ├── output015.mp3 │ ├── output016.mp3 │ ├── output017.mp3 │ ├── output018.mp3 │ ├── output019.mp3 │ ├── output020.mp3 │ ├── output021.mp3 │ ├── output022.mp3 │ ├── output023.mp3 │ ├── output024.mp3 │ ├── output025.mp3 │ ├── output027.mp3 │ ├── output028.mp3 │ └── output029.mp3 ├── test ├── README.md ├── __init__.py ├── resources │ ├── 1.png │ ├── 1.png.mp4 │ ├── 2.png │ ├── 2.png.mp4 │ ├── 3.png │ ├── 3.png.mp4 │ ├── 4.png │ ├── 5.png │ ├── 6.png │ ├── 7.png │ ├── 8.png │ └── 9.png └── services │ ├── __init__.py │ ├── test_task.py │ ├── test_video.py │ └── test_voice.py ├── webui.bat ├── webui.sh └── webui ├── .streamlit └── config.toml ├── Main.py └── i18n ├── de.json ├── en.json ├── pt.json ├── vi.json └── zh.json /.dockerignore: -------------------------------------------------------------------------------- 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 25 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 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: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yml: -------------------------------------------------------------------------------- 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 -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 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 -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Use an official Python runtime as a parent image 2 | FROM python:3.11-slim-bullseye 3 | 4 | # Set the working directory in the container 5 | WORKDIR /MoneyPrinterTurbo 6 | 7 | # 设置/MoneyPrinterTurbo目录权限为777 8 | RUN chmod 777 /MoneyPrinterTurbo 9 | 10 | ENV PYTHONPATH="/MoneyPrinterTurbo" 11 | 12 | # Install system dependencies 13 | RUN apt-get update && apt-get install -y \ 14 | git \ 15 | imagemagick \ 16 | ffmpeg \ 17 | && rm -rf /var/lib/apt/lists/* 18 | 19 | # Fix security policy for ImageMagick 20 | RUN sed -i '/<policy domain="path" rights="none" pattern="@\*"/d' /etc/ImageMagick-6/policy.xml 21 | 22 | # Copy only the requirements.txt first to leverage Docker cache 23 | COPY requirements.txt ./ 24 | 25 | # Install Python dependencies 26 | RUN pip install --no-cache-dir -r requirements.txt 27 | 28 | # Now copy the rest of the codebase into the image 29 | COPY . . 30 | 31 | # Expose the port the app runs on 32 | EXPOSE 8501 33 | 34 | # Command to run the application 35 | CMD ["streamlit", "run", "./webui/Main.py","--browser.serverAddress=127.0.0.1","--server.enableCORS=True","--browser.gatherUsageStats=False"] 36 | 37 | # 1. Build the Docker image using the following command 38 | # docker build -t moneyprinterturbo . 39 | 40 | # 2. Run the Docker container using the following command 41 | ## For Linux or MacOS: 42 | # docker run -v $(pwd)/config.toml:/MoneyPrinterTurbo/config.toml -v $(pwd)/storage:/MoneyPrinterTurbo/storage -p 8501:8501 moneyprinterturbo 43 | ## For Windows: 44 | # docker run -v ${PWD}/config.toml:/MoneyPrinterTurbo/config.toml -v ${PWD}/storage:/MoneyPrinterTurbo/storage -p 8501:8501 moneyprinterturbo -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 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-en.md: -------------------------------------------------------------------------------- 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></h3> 12 | 13 | <div align="center"> 14 | <a href="https://trendshift.io/repositories/8731" target="_blank"><img src="https://trendshift.io/api/badge/repositories/8731" alt="harry0703%2FMoneyPrinterTurbo | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a> 15 | </div> 16 | 17 | Simply provide a <b>topic</b> or <b>keyword</b> for a video, and it will automatically generate the video copy, video 18 | materials, video subtitles, and video background music before synthesizing a high-definition short video. 19 | 20 | ### WebUI 21 | 22 |  23 | 24 | ### API Interface 25 | 26 |  27 | 28 | </div> 29 | 30 | ## Special Thanks 🙏 31 | 32 | Due to the **deployment** and **usage** of this project, there is a certain threshold for some beginner users. We would 33 | like to express our special thanks to 34 | 35 | **RecCloud (AI-Powered Multimedia Service Platform)** for providing a free `AI Video Generator` service based on this 36 | project. It allows for online use without deployment, which is very convenient. 37 | 38 | - Chinese version: https://reccloud.cn 39 | - English version: https://reccloud.com 40 | 41 |  42 | 43 | ## Thanks for Sponsorship 🙏 44 | 45 | Thanks to Picwish https://picwish.com for supporting and sponsoring this project, enabling continuous updates and maintenance. 46 | 47 | 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. 48 | 49 |  50 | 51 | ## Features 🎯 52 | 53 | - [x] Complete **MVC architecture**, **clearly structured** code, easy to maintain, supports both `API` 54 | and `Web interface` 55 | - [x] Supports **AI-generated** video copy, as well as **customized copy** 56 | - [x] Supports various **high-definition video** sizes 57 | - [x] Portrait 9:16, `1080x1920` 58 | - [x] Landscape 16:9, `1920x1080` 59 | - [x] Supports **batch video generation**, allowing the creation of multiple videos at once, then selecting the most 60 | satisfactory one 61 | - [x] Supports setting the **duration of video clips**, facilitating adjustments to material switching frequency 62 | - [x] Supports video copy in both **Chinese** and **English** 63 | - [x] Supports **multiple voice** synthesis, with **real-time preview** of effects 64 | - [x] Supports **subtitle generation**, with adjustable `font`, `position`, `color`, `size`, and also 65 | supports `subtitle outlining` 66 | - [x] Supports **background music**, either random or specified music files, with adjustable `background music volume` 67 | - [x] Video material sources are **high-definition** and **royalty-free**, and you can also use your own **local materials** 68 | - [x] Supports integration with various models such as **OpenAI**, **Moonshot**, **Azure**, **gpt4free**, **one-api**, **Qwen**, **Google Gemini**, **Ollama**, **DeepSeek**, **ERNIE**, **Pollinations** and more 69 | 70 | ### Future Plans 📅 71 | 72 | - [ ] GPT-SoVITS dubbing support 73 | - [ ] Optimize voice synthesis using large models for more natural and emotionally rich voice output 74 | - [ ] Add video transition effects for a smoother viewing experience 75 | - [ ] Add more video material sources, improve the matching between video materials and script 76 | - [ ] Add video length options: short, medium, long 77 | - [ ] Support more voice synthesis providers, such as OpenAI TTS 78 | - [ ] Automate upload to YouTube platform 79 | 80 | ## Video Demos 📺 81 | 82 | ### Portrait 9:16 83 | 84 | <table> 85 | <thead> 86 | <tr> 87 | <th align="center"><g-emoji class="g-emoji" alias="arrow_forward">▶️</g-emoji> How to Add Fun to Your Life </th> 88 | <th align="center"><g-emoji class="g-emoji" alias="arrow_forward">▶️</g-emoji> What is the Meaning of Life</th> 89 | </tr> 90 | </thead> 91 | <tbody> 92 | <tr> 93 | <td align="center"><video src="https://github.com/harry0703/MoneyPrinterTurbo/assets/4928832/a84d33d5-27a2-4aba-8fd0-9fb2bd91c6a6"></video></td> 94 | <td align="center"><video src="https://github.com/harry0703/MoneyPrinterTurbo/assets/4928832/112c9564-d52b-4472-99ad-970b75f66476"></video></td> 95 | </tr> 96 | </tbody> 97 | </table> 98 | 99 | ### Landscape 16:9 100 | 101 | <table> 102 | <thead> 103 | <tr> 104 | <th align="center"><g-emoji class="g-emoji" alias="arrow_forward">▶️</g-emoji> What is the Meaning of Life</th> 105 | <th align="center"><g-emoji class="g-emoji" alias="arrow_forward">▶️</g-emoji> Why Exercise</th> 106 | </tr> 107 | </thead> 108 | <tbody> 109 | <tr> 110 | <td align="center"><video src="https://github.com/harry0703/MoneyPrinterTurbo/assets/4928832/346ebb15-c55f-47a9-a653-114f08bb8073"></video></td> 111 | <td align="center"><video src="https://github.com/harry0703/MoneyPrinterTurbo/assets/4928832/271f2fae-8283-44a0-8aa0-0ed8f9a6fa87"></video></td> 112 | </tr> 113 | </tbody> 114 | </table> 115 | 116 | ## System Requirements 📦 117 | 118 | - Recommended minimum 4 CPU cores or more, 4G of memory or more, GPU is not required 119 | - Windows 10 or MacOS 11.0, and their later versions 120 | 121 | ## Quick Start 🚀 122 | 123 | ### Run in Google Colab 124 | Want to try MoneyPrinterTurbo without setting up a local environment? Run it directly in Google Colab! 125 | 126 | [](https://colab.research.google.com/github/harry0703/MoneyPrinterTurbo/blob/main/docs/MoneyPrinterTurbo.ipynb) 127 | 128 | 129 | ### Windows 130 | 131 | Google Drive (v1.2.6): https://drive.google.com/file/d/1HsbzfT7XunkrCrHw5ncUjFX8XX4zAuUh/view?usp=sharing 132 | 133 | After downloading, it is recommended to **double-click** `update.bat` first to update to the **latest code**, then double-click `start.bat` to launch 134 | 135 | After launching, the browser will open automatically (if it opens blank, it is recommended to use **Chrome** or **Edge**) 136 | 137 | ### Other Systems 138 | 139 | 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. 140 | 141 | ## Installation & Deployment 📥 142 | 143 | ### Prerequisites 144 | 145 | #### ① Clone the Project 146 | 147 | ```shell 148 | git clone https://github.com/harry0703/MoneyPrinterTurbo.git 149 | ``` 150 | 151 | #### ② Modify the Configuration File 152 | 153 | - Copy the `config.example.toml` file and rename it to `config.toml` 154 | - Follow the instructions in the `config.toml` file to configure `pexels_api_keys` and `llm_provider`, and according to 155 | the llm_provider's service provider, set up the corresponding API Key 156 | 157 | ### Docker Deployment 🐳 158 | 159 | #### ① Launch the Docker Container 160 | 161 | If you haven't installed Docker, please install it first https://www.docker.com/products/docker-desktop/ 162 | If you are using a Windows system, please refer to Microsoft's documentation: 163 | 164 | 1. https://learn.microsoft.com/en-us/windows/wsl/install 165 | 2. https://learn.microsoft.com/en-us/windows/wsl/tutorials/wsl-containers 166 | 167 | ```shell 168 | cd MoneyPrinterTurbo 169 | docker-compose up 170 | ``` 171 | 172 | > 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 ` 173 | 174 | #### ② Access the Web Interface 175 | 176 | Open your browser and visit http://0.0.0.0:8501 177 | 178 | #### ③ Access the API Interface 179 | 180 | Open your browser and visit http://0.0.0.0:8080/docs Or http://0.0.0.0:8080/redoc 181 | 182 | ### Manual Deployment 📦 183 | 184 | #### ① Create a Python Virtual Environment 185 | 186 | It is recommended to create a Python virtual environment using [conda](https://conda.io/projects/conda/en/latest/user-guide/install/index.html) 187 | 188 | ```shell 189 | git clone https://github.com/harry0703/MoneyPrinterTurbo.git 190 | cd MoneyPrinterTurbo 191 | conda create -n MoneyPrinterTurbo python=3.11 192 | conda activate MoneyPrinterTurbo 193 | pip install -r requirements.txt 194 | ``` 195 | 196 | #### ② Install ImageMagick 197 | 198 | ###### Windows: 199 | 200 | - 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 201 | - Install the downloaded ImageMagick, **do not change the installation path** 202 | - Modify the `config.toml` configuration file, set `imagemagick_path` to your actual installation path 203 | 204 | ###### MacOS: 205 | 206 | ```shell 207 | brew install imagemagick 208 | ```` 209 | 210 | ###### Ubuntu 211 | 212 | ```shell 213 | sudo apt-get install imagemagick 214 | ``` 215 | 216 | ###### CentOS 217 | 218 | ```shell 219 | sudo yum install ImageMagick 220 | ``` 221 | 222 | #### ③ Launch the Web Interface 🌐 223 | 224 | Note that you need to execute the following commands in the `root directory` of the MoneyPrinterTurbo project 225 | 226 | ###### Windows 227 | 228 | ```bat 229 | webui.bat 230 | ``` 231 | 232 | ###### MacOS or Linux 233 | 234 | ```shell 235 | sh webui.sh 236 | ``` 237 | 238 | After launching, the browser will open automatically 239 | 240 | #### ④ Launch the API Service 🚀 241 | 242 | ```shell 243 | python main.py 244 | ``` 245 | 246 | After launching, you can view the `API documentation` at http://127.0.0.1:8080/docs and directly test the interface 247 | online for a quick experience. 248 | 249 | ## Voice Synthesis 🗣 250 | 251 | A list of all supported voices can be viewed here: [Voice List](./docs/voice-list.txt) 252 | 253 | 2024-04-16 v1.1.2 Added 9 new Azure voice synthesis voices that require API KEY configuration. These voices sound more realistic. 254 | 255 | ## Subtitle Generation 📜 256 | 257 | Currently, there are 2 ways to generate subtitles: 258 | 259 | - **edge**: Faster generation speed, better performance, no specific requirements for computer configuration, but the 260 | quality may be unstable 261 | - **whisper**: Slower generation speed, poorer performance, specific requirements for computer configuration, but more 262 | reliable quality 263 | 264 | You can switch between them by modifying the `subtitle_provider` in the `config.toml` configuration file 265 | 266 | It is recommended to use `edge` mode, and switch to `whisper` mode if the quality of the subtitles generated is not 267 | satisfactory. 268 | 269 | > Note: 270 | > 271 | > 1. In whisper mode, you need to download a model file from HuggingFace, about 3GB in size, please ensure good internet connectivity 272 | > 2. If left blank, it means no subtitles will be generated. 273 | 274 | > Since HuggingFace is not accessible in China, you can use the following methods to download the `whisper-large-v3` model file 275 | 276 | Download links: 277 | 278 | - Baidu Netdisk: https://pan.baidu.com/s/11h3Q6tsDtjQKTjUu3sc5cA?pwd=xjs9 279 | - Quark Netdisk: https://pan.quark.cn/s/3ee3d991d64b 280 | 281 | After downloading the model, extract it and place the entire directory in `.\MoneyPrinterTurbo\models`, 282 | The final file path should look like this: `.\MoneyPrinterTurbo\models\whisper-large-v3` 283 | 284 | ``` 285 | MoneyPrinterTurbo 286 | ├─models 287 | │ └─whisper-large-v3 288 | │ config.json 289 | │ model.bin 290 | │ preprocessor_config.json 291 | │ tokenizer.json 292 | │ vocabulary.json 293 | ``` 294 | 295 | ## Background Music 🎵 296 | 297 | Background music for videos is located in the project's `resource/songs` directory. 298 | > The current project includes some default music from YouTube videos. If there are copyright issues, please delete 299 | > them. 300 | 301 | ## Subtitle Fonts 🅰 302 | 303 | Fonts for rendering video subtitles are located in the project's `resource/fonts` directory, and you can also add your 304 | own fonts. 305 | 306 | ## Common Questions 🤔 307 | 308 | ### ❓RuntimeError: No ffmpeg exe could be found 309 | 310 | Normally, ffmpeg will be automatically downloaded and detected. 311 | However, if your environment has issues preventing automatic downloads, you may encounter the following error: 312 | 313 | ``` 314 | RuntimeError: No ffmpeg exe could be found. 315 | Install ffmpeg on your system, or set the IMAGEIO_FFMPEG_EXE environment variable. 316 | ``` 317 | 318 | In this case, you can download ffmpeg from https://www.gyan.dev/ffmpeg/builds/, unzip it, and set `ffmpeg_path` to your 319 | actual installation path. 320 | 321 | ```toml 322 | [app] 323 | # Please set according to your actual path, note that Windows path separators are \\ 324 | ffmpeg_path = "C:\\Users\\harry\\Downloads\\ffmpeg.exe" 325 | ``` 326 | 327 | ### ❓ImageMagick is not installed on your computer 328 | 329 | [issue 33](https://github.com/harry0703/MoneyPrinterTurbo/issues/33) 330 | 331 | 1. Follow the `example configuration` provided `download address` to 332 | install https://imagemagick.org/archive/binaries/ImageMagick-7.1.1-30-Q16-x64-static.exe, using the static library 333 | 2. Do not install in a path with Chinese characters to avoid unpredictable issues 334 | 335 | [issue 54](https://github.com/harry0703/MoneyPrinterTurbo/issues/54#issuecomment-2017842022) 336 | 337 | For Linux systems, you can manually install it, refer to https://cn.linux-console.net/?p=16978 338 | 339 | Thanks to [@wangwenqiao666](https://github.com/wangwenqiao666) for their research and exploration 340 | 341 | ### ❓ImageMagick's security policy prevents operations related to temporary file @/tmp/tmpur5hyyto.txt 342 | 343 | You can find these policies in ImageMagick's configuration file policy.xml. 344 | This file is usually located in /etc/ImageMagick-`X`/ or a similar location in the ImageMagick installation directory. 345 | Modify the entry containing `pattern="@"`, change `rights="none"` to `rights="read|write"` to allow read and write operations on files. 346 | 347 | ### ❓OSError: [Errno 24] Too many open files 348 | 349 | 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. 350 | 351 | Check the current limit: 352 | 353 | ```shell 354 | ulimit -n 355 | ``` 356 | 357 | If it's too low, you can increase it, for example: 358 | 359 | ```shell 360 | ulimit -n 10240 361 | ``` 362 | 363 | ### ❓Whisper model download failed, with the following error 364 | 365 | LocalEntryNotfoundEror: Cannot find an appropriate cached snapshotfolderfor the specified revision on the local disk and 366 | outgoing trafic has been disabled. 367 | To enablerepo look-ups and downloads online, pass 'local files only=False' as input. 368 | 369 | or 370 | 371 | An error occured while synchronizing the model Systran/faster-whisper-large-v3 from the Hugging Face Hub: 372 | An error happened while trying to locate the files on the Hub and we cannot find the appropriate snapshot folder for the 373 | specified revision on the local disk. Please check your internet connection and try again. 374 | Trying to load the model directly from the local cache, if it exists. 375 | 376 | Solution: [Click to see how to manually download the model from netdisk](#subtitle-generation-) 377 | 378 | ## Feedback & Suggestions 📢 379 | 380 | - You can submit an [issue](https://github.com/harry0703/MoneyPrinterTurbo/issues) or 381 | a [pull request](https://github.com/harry0703/MoneyPrinterTurbo/pulls). 382 | 383 | ## License 📝 384 | 385 | Click to view the [`LICENSE`](LICENSE) file 386 | 387 | ## Star History 388 | 389 | [](https://star-history.com/#harry0703/MoneyPrinterTurbo&Date) 390 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 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 | <br> 11 | <h3>简体中文 | <a href="README-en.md">English</a></h3> 12 | <div align="center"> 13 | <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> 14 | </div> 15 | <br> 16 | 只需提供一个视频 <b>主题</b> 或 <b>关键词</b> ,就可以全自动生成视频文案、视频素材、视频字幕、视频背景音乐,然后合成一个高清的短视频。 17 | <br> 18 | 19 | <h4>Web界面</h4> 20 | 21 |  22 | 23 | <h4>API界面</h4> 24 | 25 |  26 | 27 | </div> 28 | 29 | ## 特别感谢 🙏 30 | 31 | 由于该项目的 **部署** 和 **使用**,对于一些小白用户来说,还是 **有一定的门槛**,在此特别感谢 32 | **录咖(AI智能 多媒体服务平台)** 网站基于该项目,提供的免费`AI视频生成器`服务,可以不用部署,直接在线使用,非常方便。 33 | 34 | - 中文版:https://reccloud.cn 35 | - 英文版:https://reccloud.com 36 | 37 |  38 | 39 | ## 感谢赞助 🙏 40 | 41 | 感谢佐糖 https://picwish.cn 对该项目的支持和赞助,使得该项目能够持续的更新和维护。 42 | 43 | 佐糖专注于**图像处理领域**,提供丰富的**图像处理工具**,将复杂操作极致简化,真正实现让图像处理更简单。 44 | 45 |  46 | 47 | ## 功能特性 🎯 48 | 49 | - [x] 完整的 **MVC架构**,代码 **结构清晰**,易于维护,支持 `API` 和 `Web界面` 50 | - [x] 支持视频文案 **AI自动生成**,也可以**自定义文案** 51 | - [x] 支持多种 **高清视频** 尺寸 52 | - [x] 竖屏 9:16,`1080x1920` 53 | - [x] 横屏 16:9,`1920x1080` 54 | - [x] 支持 **批量视频生成**,可以一次生成多个视频,然后选择一个最满意的 55 | - [x] 支持 **视频片段时长** 设置,方便调节素材切换频率 56 | - [x] 支持 **中文** 和 **英文** 视频文案 57 | - [x] 支持 **多种语音** 合成,可 **实时试听** 效果 58 | - [x] 支持 **字幕生成**,可以调整 `字体`、`位置`、`颜色`、`大小`,同时支持`字幕描边`设置 59 | - [x] 支持 **背景音乐**,随机或者指定音乐文件,可设置`背景音乐音量` 60 | - [x] 视频素材来源 **高清**,而且 **无版权**,也可以使用自己的 **本地素材** 61 | - [x] 支持 **OpenAI**、**Moonshot**、**Azure**、**gpt4free**、**one-api**、**通义千问**、**Google Gemini**、**Ollama**、**DeepSeek**、 **文心一言**, **Pollinations** 等多种模型接入 62 | - 中国用户建议使用 **DeepSeek** 或 **Moonshot** 作为大模型提供商(国内可直接访问,不需要VPN。注册就送额度,基本够用) 63 | 64 | 65 | ### 后期计划 📅 66 | 67 | - [ ] GPT-SoVITS 配音支持 68 | - [ ] 优化语音合成,利用大模型,使其合成的声音,更加自然,情绪更加丰富 69 | - [ ] 增加视频转场效果,使其看起来更加的流畅 70 | - [ ] 增加更多视频素材来源,优化视频素材和文案的匹配度 71 | - [ ] 增加视频长度选项:短、中、长 72 | - [ ] 支持更多的语音合成服务商,比如 OpenAI TTS 73 | - [ ] 自动上传到YouTube平台 74 | 75 | ## 视频演示 📺 76 | 77 | ### 竖屏 9:16 78 | 79 | <table> 80 | <thead> 81 | <tr> 82 | <th align="center"><g-emoji class="g-emoji" alias="arrow_forward">▶️</g-emoji> 《如何增加生活的乐趣》</th> 83 | <th align="center"><g-emoji class="g-emoji" alias="arrow_forward">▶️</g-emoji> 《金钱的作用》<br>更真实的合成声音</th> 84 | <th align="center"><g-emoji class="g-emoji" alias="arrow_forward">▶️</g-emoji> 《生命的意义是什么》</th> 85 | </tr> 86 | </thead> 87 | <tbody> 88 | <tr> 89 | <td align="center"><video src="https://github.com/harry0703/MoneyPrinterTurbo/assets/4928832/a84d33d5-27a2-4aba-8fd0-9fb2bd91c6a6"></video></td> 90 | <td align="center"><video src="https://github.com/harry0703/MoneyPrinterTurbo/assets/4928832/af2f3b0b-002e-49fe-b161-18ba91c055e8"></video></td> 91 | <td align="center"><video src="https://github.com/harry0703/MoneyPrinterTurbo/assets/4928832/112c9564-d52b-4472-99ad-970b75f66476"></video></td> 92 | </tr> 93 | </tbody> 94 | </table> 95 | 96 | ### 横屏 16:9 97 | 98 | <table> 99 | <thead> 100 | <tr> 101 | <th align="center"><g-emoji class="g-emoji" alias="arrow_forward">▶️</g-emoji>《生命的意义是什么》</th> 102 | <th align="center"><g-emoji class="g-emoji" alias="arrow_forward">▶️</g-emoji>《为什么要运动》</th> 103 | </tr> 104 | </thead> 105 | <tbody> 106 | <tr> 107 | <td align="center"><video src="https://github.com/harry0703/MoneyPrinterTurbo/assets/4928832/346ebb15-c55f-47a9-a653-114f08bb8073"></video></td> 108 | <td align="center"><video src="https://github.com/harry0703/MoneyPrinterTurbo/assets/4928832/271f2fae-8283-44a0-8aa0-0ed8f9a6fa87"></video></td> 109 | </tr> 110 | </tbody> 111 | </table> 112 | 113 | ## 配置要求 📦 114 | 115 | - 建议最低 CPU **4核** 或以上,内存 **4G** 或以上,显卡非必须 116 | - Windows 10 或 MacOS 11.0 以上系统 117 | 118 | 119 | ## 快速开始 🚀 120 | 121 | ### 在 Google Colab 中运行 122 | 免去本地环境配置,点击直接在 Google Colab 中快速体验 MoneyPrinterTurbo 123 | 124 | [](https://colab.research.google.com/github/harry0703/MoneyPrinterTurbo/blob/main/docs/MoneyPrinterTurbo.ipynb) 125 | 126 | 127 | ### Windows一键启动包 128 | 129 | 下载一键启动包,解压直接使用(路径不要有 **中文**、**特殊字符**、**空格**) 130 | 131 | - 百度网盘(v1.2.6): https://pan.baidu.com/s/1wg0UaIyXpO3SqIpaq790SQ?pwd=sbqx 提取码: sbqx 132 | - Google Drive (v1.2.6): https://drive.google.com/file/d/1HsbzfT7XunkrCrHw5ncUjFX8XX4zAuUh/view?usp=sharing 133 | 134 | 下载后,建议先**双击执行** `update.bat` 更新到**最新代码**,然后双击 `start.bat` 启动 135 | 136 | 启动后,会自动打开浏览器(如果打开是空白,建议换成 **Chrome** 或者 **Edge** 打开) 137 | 138 | ## 安装部署 📥 139 | 140 | ### 前提条件 141 | 142 | - 尽量不要使用 **中文路径**,避免出现一些无法预料的问题 143 | - 请确保你的 **网络** 是正常的,VPN需要打开`全局流量`模式 144 | 145 | #### ① 克隆代码 146 | 147 | ```shell 148 | git clone https://github.com/harry0703/MoneyPrinterTurbo.git 149 | ``` 150 | 151 | #### ② 修改配置文件(可选,建议启动后也可以在 WebUI 里面配置) 152 | 153 | - 将 `config.example.toml` 文件复制一份,命名为 `config.toml` 154 | - 按照 `config.toml` 文件中的说明,配置好 `pexels_api_keys` 和 `llm_provider`,并根据 llm_provider 对应的服务商,配置相关的 155 | API Key 156 | 157 | ### Docker部署 🐳 158 | 159 | #### ① 启动Docker 160 | 161 | 如果未安装 Docker,请先安装 https://www.docker.com/products/docker-desktop/ 162 | 163 | 如果是Windows系统,请参考微软的文档: 164 | 165 | 1. https://learn.microsoft.com/zh-cn/windows/wsl/install 166 | 2. https://learn.microsoft.com/zh-cn/windows/wsl/tutorials/wsl-containers 167 | 168 | ```shell 169 | cd MoneyPrinterTurbo 170 | docker-compose up 171 | ``` 172 | 173 | > 注意:最新版的docker安装时会自动以插件的形式安装docker compose,启动命令调整为docker compose up 174 | 175 | #### ② 访问Web界面 176 | 177 | 打开浏览器,访问 http://0.0.0.0:8501 178 | 179 | #### ③ 访问API文档 180 | 181 | 打开浏览器,访问 http://0.0.0.0:8080/docs 或者 http://0.0.0.0:8080/redoc 182 | 183 | ### 手动部署 📦 184 | 185 | > 视频教程 186 | 187 | - 完整的使用演示:https://v.douyin.com/iFhnwsKY/ 188 | - 如何在Windows上部署:https://v.douyin.com/iFyjoW3M 189 | 190 | #### ① 创建虚拟环境 191 | 192 | 建议使用 [conda](https://conda.io/projects/conda/en/latest/user-guide/install/index.html) 创建 python 虚拟环境 193 | 194 | ```shell 195 | git clone https://github.com/harry0703/MoneyPrinterTurbo.git 196 | cd MoneyPrinterTurbo 197 | conda create -n MoneyPrinterTurbo python=3.11 198 | conda activate MoneyPrinterTurbo 199 | pip install -r requirements.txt 200 | ``` 201 | 202 | #### ② 安装好 ImageMagick 203 | 204 | - Windows: 205 | - 下载 https://imagemagick.org/script/download.php 选择Windows版本,切记一定要选择 **静态库** 版本,比如 206 | ImageMagick-7.1.1-32-Q16-x64-**static**.exe 207 | - 安装下载好的 ImageMagick,**注意不要修改安装路径** 208 | - 修改 `配置文件 config.toml` 中的 `imagemagick_path` 为你的 **实际安装路径** 209 | 210 | - MacOS: 211 | ```shell 212 | brew install imagemagick 213 | ```` 214 | - Ubuntu 215 | ```shell 216 | sudo apt-get install imagemagick 217 | ``` 218 | - CentOS 219 | ```shell 220 | sudo yum install ImageMagick 221 | ``` 222 | 223 | #### ③ 启动Web界面 🌐 224 | 225 | 注意需要到 MoneyPrinterTurbo 项目 `根目录` 下执行以下命令 226 | 227 | ###### Windows 228 | 229 | ```bat 230 | webui.bat 231 | ``` 232 | 233 | ###### MacOS or Linux 234 | 235 | ```shell 236 | sh webui.sh 237 | ``` 238 | 239 | 启动后,会自动打开浏览器(如果打开是空白,建议换成 **Chrome** 或者 **Edge** 打开) 240 | 241 | #### ④ 启动API服务 🚀 242 | 243 | ```shell 244 | python main.py 245 | ``` 246 | 247 | 启动后,可以查看 `API文档` http://127.0.0.1:8080/docs 或者 http://127.0.0.1:8080/redoc 直接在线调试接口,快速体验。 248 | 249 | ## 语音合成 🗣 250 | 251 | 所有支持的声音列表,可以查看:[声音列表](./docs/voice-list.txt) 252 | 253 | 2024-04-16 v1.1.2 新增了9种Azure的语音合成声音,需要配置API KEY,该声音合成的更加真实。 254 | 255 | ## 字幕生成 📜 256 | 257 | 当前支持2种字幕生成方式: 258 | 259 | - **edge**: 生成`速度快`,性能更好,对电脑配置没有要求,但是质量可能不稳定 260 | - **whisper**: 生成`速度慢`,性能较差,对电脑配置有一定要求,但是`质量更可靠`。 261 | 262 | 可以修改 `config.toml` 配置文件中的 `subtitle_provider` 进行切换 263 | 264 | 建议使用 `edge` 模式,如果生成的字幕质量不好,再切换到 `whisper` 模式 265 | 266 | > 注意: 267 | 268 | 1. whisper 模式下需要到 HuggingFace 下载一个模型文件,大约 3GB 左右,请确保网络通畅 269 | 2. 如果留空,表示不生成字幕。 270 | 271 | > 由于国内无法访问 HuggingFace,可以使用以下方法下载 `whisper-large-v3` 的模型文件 272 | 273 | 下载地址: 274 | 275 | - 百度网盘: https://pan.baidu.com/s/11h3Q6tsDtjQKTjUu3sc5cA?pwd=xjs9 276 | - 夸克网盘:https://pan.quark.cn/s/3ee3d991d64b 277 | 278 | 模型下载后解压,整个目录放到 `.\MoneyPrinterTurbo\models` 里面, 279 | 最终的文件路径应该是这样: `.\MoneyPrinterTurbo\models\whisper-large-v3` 280 | 281 | ``` 282 | MoneyPrinterTurbo 283 | ├─models 284 | │ └─whisper-large-v3 285 | │ config.json 286 | │ model.bin 287 | │ preprocessor_config.json 288 | │ tokenizer.json 289 | │ vocabulary.json 290 | ``` 291 | 292 | ## 背景音乐 🎵 293 | 294 | 用于视频的背景音乐,位于项目的 `resource/songs` 目录下。 295 | > 当前项目里面放了一些默认的音乐,来自于 YouTube 视频,如有侵权,请删除。 296 | 297 | ## 字幕字体 🅰 298 | 299 | 用于视频字幕的渲染,位于项目的 `resource/fonts` 目录下,你也可以放进去自己的字体。 300 | 301 | ## 常见问题 🤔 302 | 303 | ### ❓RuntimeError: No ffmpeg exe could be found 304 | 305 | 通常情况下,ffmpeg 会被自动下载,并且会被自动检测到。 306 | 但是如果你的环境有问题,无法自动下载,可能会遇到如下错误: 307 | 308 | ``` 309 | RuntimeError: No ffmpeg exe could be found. 310 | Install ffmpeg on your system, or set the IMAGEIO_FFMPEG_EXE environment variable. 311 | ``` 312 | 313 | 此时你可以从 https://www.gyan.dev/ffmpeg/builds/ 下载ffmpeg,解压后,设置 `ffmpeg_path` 为你的实际安装路径即可。 314 | 315 | ```toml 316 | [app] 317 | # 请根据你的实际路径设置,注意 Windows 路径分隔符为 \\ 318 | ffmpeg_path = "C:\\Users\\harry\\Downloads\\ffmpeg.exe" 319 | ``` 320 | 321 | ### ❓ImageMagick的安全策略阻止了与临时文件@/tmp/tmpur5hyyto.txt相关的操作 322 | 323 | 可以在ImageMagick的配置文件policy.xml中找到这些策略。 324 | 这个文件通常位于 /etc/ImageMagick-`X`/ 或 ImageMagick 安装目录的类似位置。 325 | 修改包含`pattern="@"`的条目,将`rights="none"`更改为`rights="read|write"`以允许对文件的读写操作。 326 | 327 | ### ❓OSError: [Errno 24] Too many open files 328 | 329 | 这个问题是由于系统打开文件数限制导致的,可以通过修改系统的文件打开数限制来解决。 330 | 331 | 查看当前限制 332 | 333 | ```shell 334 | ulimit -n 335 | ``` 336 | 337 | 如果过低,可以调高一些,比如 338 | 339 | ```shell 340 | ulimit -n 10240 341 | ``` 342 | 343 | ### ❓Whisper 模型下载失败,出现如下错误 344 | 345 | LocalEntryNotfoundEror: Cannot find an appropriate cached snapshotfolderfor the specified revision on the local disk and 346 | outgoing trafic has been disabled. 347 | To enablerepo look-ups and downloads online, pass 'local files only=False' as input. 348 | 349 | 或者 350 | 351 | An error occured while synchronizing the model Systran/faster-whisper-large-v3 from the Hugging Face Hub: 352 | An error happened while trying to locate the files on the Hub and we cannot find the appropriate snapshot folder for the 353 | specified revision on the local disk. Please check your internet connection and try again. 354 | Trying to load the model directly from the local cache, if it exists. 355 | 356 | 解决方法:[点击查看如何从网盘手动下载模型](#%E5%AD%97%E5%B9%95%E7%94%9F%E6%88%90-) 357 | 358 | ## 反馈建议 📢 359 | 360 | - 可以提交 [issue](https://github.com/harry0703/MoneyPrinterTurbo/issues) 361 | 或者 [pull request](https://github.com/harry0703/MoneyPrinterTurbo/pulls)。 362 | 363 | ## 许可证 📝 364 | 365 | 点击查看 [`LICENSE`](LICENSE) 文件 366 | 367 | ## Star History 368 | 369 | [](https://star-history.com/#harry0703/MoneyPrinterTurbo&Date) -------------------------------------------------------------------------------- /app/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/harry0703/MoneyPrinterTurbo/6cb5f2348714b08fd7cf04a416c13faeab1d356b/app/__init__.py -------------------------------------------------------------------------------- /app/asgi.py: -------------------------------------------------------------------------------- 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") 83 | -------------------------------------------------------------------------------- /app/config/__init__.py: -------------------------------------------------------------------------------- 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() 57 | -------------------------------------------------------------------------------- /app/config/config.py: -------------------------------------------------------------------------------- 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 | 11 | 12 | def load_config(): 13 | # fix: IsADirectoryError: [Errno 21] Is a directory: '/MoneyPrinterTurbo/config.toml' 14 | if os.path.isdir(config_file): 15 | shutil.rmtree(config_file) 16 | 17 | if not os.path.isfile(config_file): 18 | example_file = f"{root_dir}/config.example.toml" 19 | if os.path.isfile(example_file): 20 | shutil.copyfile(example_file, config_file) 21 | logger.info("copy config.example.toml to config.toml") 22 | 23 | logger.info(f"load config from file: {config_file}") 24 | 25 | try: 26 | _config_ = toml.load(config_file) 27 | except Exception as e: 28 | logger.warning(f"load config failed: {str(e)}, try to load as utf-8-sig") 29 | with open(config_file, mode="r", encoding="utf-8-sig") as fp: 30 | _cfg_content = fp.read() 31 | _config_ = toml.loads(_cfg_content) 32 | return _config_ 33 | 34 | 35 | def save_config(): 36 | with open(config_file, "w", encoding="utf-8") as f: 37 | _cfg["app"] = app 38 | _cfg["azure"] = azure 39 | _cfg["siliconflow"] = siliconflow 40 | _cfg["ui"] = ui 41 | f.write(toml.dumps(_cfg)) 42 | 43 | 44 | _cfg = load_config() 45 | app = _cfg.get("app", {}) 46 | whisper = _cfg.get("whisper", {}) 47 | proxy = _cfg.get("proxy", {}) 48 | azure = _cfg.get("azure", {}) 49 | siliconflow = _cfg.get("siliconflow", {}) 50 | ui = _cfg.get( 51 | "ui", 52 | { 53 | "hide_log": False, 54 | }, 55 | ) 56 | 57 | hostname = socket.gethostname() 58 | 59 | log_level = _cfg.get("log_level", "DEBUG") 60 | listen_host = _cfg.get("listen_host", "0.0.0.0") 61 | listen_port = _cfg.get("listen_port", 8080) 62 | project_name = _cfg.get("project_name", "MoneyPrinterTurbo") 63 | project_description = _cfg.get( 64 | "project_description", 65 | "<a href='https://github.com/harry0703/MoneyPrinterTurbo'>https://github.com/harry0703/MoneyPrinterTurbo</a>", 66 | ) 67 | project_version = _cfg.get("project_version", "1.2.6") 68 | reload_debug = False 69 | 70 | imagemagick_path = app.get("imagemagick_path", "") 71 | if imagemagick_path and os.path.isfile(imagemagick_path): 72 | os.environ["IMAGEMAGICK_BINARY"] = imagemagick_path 73 | 74 | ffmpeg_path = app.get("ffmpeg_path", "") 75 | if ffmpeg_path and os.path.isfile(ffmpeg_path): 76 | os.environ["IMAGEIO_FFMPEG_EXE"] = ffmpeg_path 77 | 78 | logger.info(f"{project_name} v{project_version}") 79 | -------------------------------------------------------------------------------- /app/controllers/base.py: -------------------------------------------------------------------------------- 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 | ) 32 | -------------------------------------------------------------------------------- /app/controllers/manager/base_manager.py: -------------------------------------------------------------------------------- 1 | import threading 2 | from typing import Any, Callable, Dict 3 | 4 | 5 | class TaskManager: 6 | def __init__(self, max_concurrent_tasks: int): 7 | self.max_concurrent_tasks = max_concurrent_tasks 8 | self.current_tasks = 0 9 | self.lock = threading.Lock() 10 | self.queue = self.create_queue() 11 | 12 | def create_queue(self): 13 | raise NotImplementedError() 14 | 15 | def add_task(self, func: Callable, *args: Any, **kwargs: Any): 16 | with self.lock: 17 | if self.current_tasks < self.max_concurrent_tasks: 18 | print(f"add task: {func.__name__}, current_tasks: {self.current_tasks}") 19 | self.execute_task(func, *args, **kwargs) 20 | else: 21 | print( 22 | f"enqueue task: {func.__name__}, current_tasks: {self.current_tasks}" 23 | ) 24 | self.enqueue({"func": func, "args": args, "kwargs": kwargs}) 25 | 26 | def execute_task(self, func: Callable, *args: Any, **kwargs: Any): 27 | thread = threading.Thread( 28 | target=self.run_task, args=(func, *args), kwargs=kwargs 29 | ) 30 | thread.start() 31 | 32 | def run_task(self, func: Callable, *args: Any, **kwargs: Any): 33 | try: 34 | with self.lock: 35 | self.current_tasks += 1 36 | func(*args, **kwargs) # call the function here, passing *args and **kwargs. 37 | finally: 38 | self.task_done() 39 | 40 | def check_queue(self): 41 | with self.lock: 42 | if ( 43 | self.current_tasks < self.max_concurrent_tasks 44 | and not self.is_queue_empty() 45 | ): 46 | task_info = self.dequeue() 47 | func = task_info["func"] 48 | args = task_info.get("args", ()) 49 | kwargs = task_info.get("kwargs", {}) 50 | self.execute_task(func, *args, **kwargs) 51 | 52 | def task_done(self): 53 | with self.lock: 54 | self.current_tasks -= 1 55 | self.check_queue() 56 | 57 | def enqueue(self, task: Dict): 58 | raise NotImplementedError() 59 | 60 | def dequeue(self): 61 | raise NotImplementedError() 62 | 63 | def is_queue_empty(self): 64 | raise NotImplementedError() 65 | -------------------------------------------------------------------------------- /app/controllers/manager/memory_manager.py: -------------------------------------------------------------------------------- 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() 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 | -------------------------------------------------------------------------------- /app/controllers/manager/redis_manager.py: -------------------------------------------------------------------------------- 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__(self, max_concurrent_tasks: int, redis_url: str): 18 | self.redis_client = redis.Redis.from_url(redis_url) 19 | super().__init__(max_concurrent_tasks) 20 | 21 | def create_queue(self): 22 | return "task_queue" 23 | 24 | def enqueue(self, task: Dict): 25 | task_with_serializable_params = task.copy() 26 | 27 | if "params" in task["kwargs"] and isinstance( 28 | task["kwargs"]["params"], VideoParams 29 | ): 30 | task_with_serializable_params["kwargs"]["params"] = task["kwargs"][ 31 | "params" 32 | ].dict() 33 | 34 | # 将函数对象转换为其名称 35 | task_with_serializable_params["func"] = task["func"].__name__ 36 | self.redis_client.rpush(self.queue, json.dumps(task_with_serializable_params)) 37 | 38 | def dequeue(self): 39 | task_json = self.redis_client.lpop(self.queue) 40 | if task_json: 41 | task_info = json.loads(task_json) 42 | # 将函数名称转换回函数对象 43 | task_info["func"] = FUNC_MAP[task_info["func"]] 44 | 45 | if "params" in task_info["kwargs"] and isinstance( 46 | task_info["kwargs"]["params"], dict 47 | ): 48 | task_info["kwargs"]["params"] = VideoParams( 49 | **task_info["kwargs"]["params"] 50 | ) 51 | 52 | return task_info 53 | return None 54 | 55 | def is_queue_empty(self): 56 | return self.redis_client.llen(self.queue) == 0 57 | -------------------------------------------------------------------------------- /app/controllers/ping.py: -------------------------------------------------------------------------------- 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" 14 | -------------------------------------------------------------------------------- /app/controllers/v1/base.py: -------------------------------------------------------------------------------- 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 12 | -------------------------------------------------------------------------------- /app/controllers/v1/llm.py: -------------------------------------------------------------------------------- 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 | ) 29 | response = {"video_script": video_script} 30 | return utils.get_response(200, response) 31 | 32 | 33 | @router.post( 34 | "/terms", 35 | response_model=VideoTermsResponse, 36 | summary="Generate video terms based on the video script", 37 | ) 38 | def generate_video_terms(request: Request, body: VideoTermsRequest): 39 | video_terms = llm.generate_terms( 40 | video_subject=body.video_subject, 41 | video_script=body.video_script, 42 | amount=body.amount, 43 | ) 44 | response = {"video_terms": video_terms} 45 | return utils.get_response(200, response) 46 | -------------------------------------------------------------------------------- /app/controllers/v1/video.py: -------------------------------------------------------------------------------- 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, 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.memory_manager import InMemoryTaskManager 15 | from app.controllers.manager.redis_manager import RedisTaskManager 16 | from app.controllers.v1.base import new_router 17 | from app.models.exception import HttpException 18 | from app.models.schema import ( 19 | AudioRequest, 20 | BgmRetrieveResponse, 21 | BgmUploadResponse, 22 | SubtitleRequest, 23 | TaskDeletionResponse, 24 | TaskQueryRequest, 25 | TaskQueryResponse, 26 | TaskResponse, 27 | TaskVideoRequest, 28 | ) 29 | from app.services import state as sm 30 | from app.services import task as tm 31 | from app.utils import utils 32 | 33 | # 认证依赖项 34 | # router = new_router(dependencies=[Depends(base.verify_token)]) 35 | router = new_router() 36 | 37 | _enable_redis = config.app.get("enable_redis", False) 38 | _redis_host = config.app.get("redis_host", "localhost") 39 | _redis_port = config.app.get("redis_port", 6379) 40 | _redis_db = config.app.get("redis_db", 0) 41 | _redis_password = config.app.get("redis_password", None) 42 | _max_concurrent_tasks = config.app.get("max_concurrent_tasks", 5) 43 | 44 | redis_url = f"redis://:{_redis_password}@{_redis_host}:{_redis_port}/{_redis_db}" 45 | # 根据配置选择合适的任务管理器 46 | if _enable_redis: 47 | task_manager = RedisTaskManager( 48 | max_concurrent_tasks=_max_concurrent_tasks, redis_url=redis_url 49 | ) 50 | else: 51 | task_manager = InMemoryTaskManager(max_concurrent_tasks=_max_concurrent_tasks) 52 | 53 | 54 | @router.post("/videos", response_model=TaskResponse, summary="Generate a short video") 55 | def create_video( 56 | background_tasks: BackgroundTasks, request: Request, body: TaskVideoRequest 57 | ): 58 | return create_task(request, body, stop_at="video") 59 | 60 | 61 | @router.post("/subtitle", response_model=TaskResponse, summary="Generate subtitle only") 62 | def create_subtitle( 63 | background_tasks: BackgroundTasks, request: Request, body: SubtitleRequest 64 | ): 65 | return create_task(request, body, stop_at="subtitle") 66 | 67 | 68 | @router.post("/audio", response_model=TaskResponse, summary="Generate audio only") 69 | def create_audio( 70 | background_tasks: BackgroundTasks, request: Request, body: AudioRequest 71 | ): 72 | return create_task(request, body, stop_at="audio") 73 | 74 | 75 | def create_task( 76 | request: Request, 77 | body: Union[TaskVideoRequest, SubtitleRequest, AudioRequest], 78 | stop_at: str, 79 | ): 80 | task_id = utils.get_uuid() 81 | request_id = base.get_task_id(request) 82 | try: 83 | task = { 84 | "task_id": task_id, 85 | "request_id": request_id, 86 | "params": body.model_dump(), 87 | } 88 | sm.state.update_task(task_id) 89 | task_manager.add_task(tm.start, task_id=task_id, params=body, stop_at=stop_at) 90 | logger.success(f"Task created: {utils.to_json(task)}") 91 | return utils.get_response(200, task) 92 | except ValueError as e: 93 | raise HttpException( 94 | task_id=task_id, status_code=400, message=f"{request_id}: {str(e)}" 95 | ) 96 | 97 | from fastapi import Query 98 | 99 | @router.get("/tasks", response_model=TaskQueryResponse, summary="Get all tasks") 100 | def get_all_tasks(request: Request, page: int = Query(1, ge=1), page_size: int = Query(10, ge=1)): 101 | request_id = base.get_task_id(request) 102 | tasks, total = sm.state.get_all_tasks(page, page_size) 103 | 104 | response = { 105 | "tasks": tasks, 106 | "total": total, 107 | "page": page, 108 | "page_size": page_size, 109 | } 110 | return utils.get_response(200, response) 111 | 112 | 113 | 114 | @router.get( 115 | "/tasks/{task_id}", response_model=TaskQueryResponse, summary="Query task status" 116 | ) 117 | def get_task( 118 | request: Request, 119 | task_id: str = Path(..., description="Task ID"), 120 | query: TaskQueryRequest = Depends(), 121 | ): 122 | endpoint = config.app.get("endpoint", "") 123 | if not endpoint: 124 | endpoint = str(request.base_url) 125 | endpoint = endpoint.rstrip("/") 126 | 127 | request_id = base.get_task_id(request) 128 | task = sm.state.get_task(task_id) 129 | if task: 130 | task_dir = utils.task_dir() 131 | 132 | def file_to_uri(file): 133 | if not file.startswith(endpoint): 134 | _uri_path = v.replace(task_dir, "tasks").replace("\\", "/") 135 | _uri_path = f"{endpoint}/{_uri_path}" 136 | else: 137 | _uri_path = file 138 | return _uri_path 139 | 140 | if "videos" in task: 141 | videos = task["videos"] 142 | urls = [] 143 | for v in videos: 144 | urls.append(file_to_uri(v)) 145 | task["videos"] = urls 146 | if "combined_videos" in task: 147 | combined_videos = task["combined_videos"] 148 | urls = [] 149 | for v in combined_videos: 150 | urls.append(file_to_uri(v)) 151 | task["combined_videos"] = urls 152 | return utils.get_response(200, task) 153 | 154 | raise HttpException( 155 | task_id=task_id, status_code=404, message=f"{request_id}: task not found" 156 | ) 157 | 158 | 159 | @router.delete( 160 | "/tasks/{task_id}", 161 | response_model=TaskDeletionResponse, 162 | summary="Delete a generated short video task", 163 | ) 164 | def delete_video(request: Request, task_id: str = Path(..., description="Task ID")): 165 | request_id = base.get_task_id(request) 166 | task = sm.state.get_task(task_id) 167 | if task: 168 | tasks_dir = utils.task_dir() 169 | current_task_dir = os.path.join(tasks_dir, task_id) 170 | if os.path.exists(current_task_dir): 171 | shutil.rmtree(current_task_dir) 172 | 173 | sm.state.delete_task(task_id) 174 | logger.success(f"video deleted: {utils.to_json(task)}") 175 | return utils.get_response(200) 176 | 177 | raise HttpException( 178 | task_id=task_id, status_code=404, message=f"{request_id}: task not found" 179 | ) 180 | 181 | 182 | @router.get( 183 | "/musics", response_model=BgmRetrieveResponse, summary="Retrieve local BGM files" 184 | ) 185 | def get_bgm_list(request: Request): 186 | suffix = "*.mp3" 187 | song_dir = utils.song_dir() 188 | files = glob.glob(os.path.join(song_dir, suffix)) 189 | bgm_list = [] 190 | for file in files: 191 | bgm_list.append( 192 | { 193 | "name": os.path.basename(file), 194 | "size": os.path.getsize(file), 195 | "file": file, 196 | } 197 | ) 198 | response = {"files": bgm_list} 199 | return utils.get_response(200, response) 200 | 201 | 202 | @router.post( 203 | "/musics", 204 | response_model=BgmUploadResponse, 205 | summary="Upload the BGM file to the songs directory", 206 | ) 207 | def upload_bgm_file(request: Request, file: UploadFile = File(...)): 208 | request_id = base.get_task_id(request) 209 | # check file ext 210 | if file.filename.endswith("mp3"): 211 | song_dir = utils.song_dir() 212 | save_path = os.path.join(song_dir, file.filename) 213 | # save file 214 | with open(save_path, "wb+") as buffer: 215 | # If the file already exists, it will be overwritten 216 | file.file.seek(0) 217 | buffer.write(file.file.read()) 218 | response = {"file": save_path} 219 | return utils.get_response(200, response) 220 | 221 | raise HttpException( 222 | "", status_code=400, message=f"{request_id}: Only *.mp3 files can be uploaded" 223 | ) 224 | 225 | 226 | @router.get("/stream/{file_path:path}") 227 | async def stream_video(request: Request, file_path: str): 228 | tasks_dir = utils.task_dir() 229 | video_path = os.path.join(tasks_dir, file_path) 230 | range_header = request.headers.get("Range") 231 | video_size = os.path.getsize(video_path) 232 | start, end = 0, video_size - 1 233 | 234 | length = video_size 235 | if range_header: 236 | range_ = range_header.split("bytes=")[1] 237 | start, end = [int(part) if part else None for part in range_.split("-")] 238 | if start is None: 239 | start = video_size - end 240 | end = video_size - 1 241 | if end is None: 242 | end = video_size - 1 243 | length = end - start + 1 244 | 245 | def file_iterator(file_path, offset=0, bytes_to_read=None): 246 | with open(file_path, "rb") as f: 247 | f.seek(offset, os.SEEK_SET) 248 | remaining = bytes_to_read or video_size 249 | while remaining > 0: 250 | bytes_to_read = min(4096, remaining) 251 | data = f.read(bytes_to_read) 252 | if not data: 253 | break 254 | remaining -= len(data) 255 | yield data 256 | 257 | response = StreamingResponse( 258 | file_iterator(video_path, start, length), media_type="video/mp4" 259 | ) 260 | response.headers["Content-Range"] = f"bytes {start}-{end}/{video_size}" 261 | response.headers["Accept-Ranges"] = "bytes" 262 | response.headers["Content-Length"] = str(length) 263 | response.status_code = 206 # Partial Content 264 | 265 | return response 266 | 267 | 268 | @router.get("/download/{file_path:path}") 269 | async def download_video(_: Request, file_path: str): 270 | """ 271 | download video 272 | :param _: Request request 273 | :param file_path: video file path, eg: /cd1727ed-3473-42a2-a7da-4faafafec72b/final-1.mp4 274 | :return: video file 275 | """ 276 | tasks_dir = utils.task_dir() 277 | video_path = os.path.join(tasks_dir, file_path) 278 | file_path = pathlib.Path(video_path) 279 | filename = file_path.stem 280 | extension = file_path.suffix 281 | headers = {"Content-Disposition": f"attachment; filename={filename}{extension}"} 282 | return FileResponse( 283 | path=video_path, 284 | headers=headers, 285 | filename=f"{filename}{extension}", 286 | media_type=f"video/{extension[1:]}", 287 | ) 288 | -------------------------------------------------------------------------------- /app/models/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/harry0703/MoneyPrinterTurbo/6cb5f2348714b08fd7cf04a416c13faeab1d356b/app/models/__init__.py -------------------------------------------------------------------------------- /app/models/const.py: -------------------------------------------------------------------------------- 1 | PUNCTUATIONS = [ 2 | "?", 3 | ",", 4 | ".", 5 | "、", 6 | ";", 7 | ":", 8 | "!", 9 | "…", 10 | "?", 11 | ",", 12 | "。", 13 | "、", 14 | ";", 15 | ":", 16 | "!", 17 | "...", 18 | ] 19 | 20 | TASK_STATE_FAILED = -1 21 | TASK_STATE_COMPLETE = 1 22 | TASK_STATE_PROCESSING = 4 23 | 24 | FILE_TYPE_VIDEOS = ["mp4", "mov", "mkv", "webm"] 25 | FILE_TYPE_IMAGES = ["jpg", "jpeg", "png", "bmp"] 26 | -------------------------------------------------------------------------------- /app/models/exception.py: -------------------------------------------------------------------------------- 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 29 | -------------------------------------------------------------------------------- /app/models/schema.py: -------------------------------------------------------------------------------- 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 7 | 8 | # 忽略 Pydantic 的特定警告 9 | warnings.filterwarnings( 10 | "ignore", 11 | category=UserWarning, 12 | message="Field name.*shadows an attribute in parent.*", 13 | ) 14 | 15 | 16 | class VideoConcatMode(str, Enum): 17 | random = "random" 18 | sequential = "sequential" 19 | 20 | 21 | class VideoTransitionMode(str, Enum): 22 | none = None 23 | shuffle = "Shuffle" 24 | fade_in = "FadeIn" 25 | fade_out = "FadeOut" 26 | slide_in = "SlideIn" 27 | slide_out = "SlideOut" 28 | 29 | 30 | class VideoAspect(str, Enum): 31 | landscape = "16:9" 32 | portrait = "9:16" 33 | square = "1:1" 34 | 35 | def to_resolution(self): 36 | if self == VideoAspect.landscape.value: 37 | return 1920, 1080 38 | elif self == VideoAspect.portrait.value: 39 | return 1080, 1920 40 | elif self == VideoAspect.square.value: 41 | return 1080, 1080 42 | return 1080, 1920 43 | 44 | 45 | class _Config: 46 | arbitrary_types_allowed = True 47 | 48 | 49 | @pydantic.dataclasses.dataclass(config=_Config) 50 | class MaterialInfo: 51 | provider: str = "pexels" 52 | url: str = "" 53 | duration: int = 0 54 | 55 | 56 | class VideoParams(BaseModel): 57 | """ 58 | { 59 | "video_subject": "", 60 | "video_aspect": "横屏 16:9(西瓜视频)", 61 | "voice_name": "女生-晓晓", 62 | "bgm_name": "random", 63 | "font_name": "STHeitiMedium 黑体-中", 64 | "text_color": "#FFFFFF", 65 | "font_size": 60, 66 | "stroke_color": "#000000", 67 | "stroke_width": 1.5 68 | } 69 | """ 70 | 71 | video_subject: str 72 | video_script: str = "" # Script used to generate the video 73 | video_terms: Optional[str | list] = None # Keywords used to generate the video 74 | video_aspect: Optional[VideoAspect] = VideoAspect.portrait.value 75 | video_concat_mode: Optional[VideoConcatMode] = VideoConcatMode.random.value 76 | video_transition_mode: Optional[VideoTransitionMode] = None 77 | video_clip_duration: Optional[int] = 5 78 | video_count: Optional[int] = 1 79 | 80 | video_source: Optional[str] = "pexels" 81 | video_materials: Optional[List[MaterialInfo]] = ( 82 | None # Materials used to generate the video 83 | ) 84 | 85 | video_language: Optional[str] = "" # auto detect 86 | 87 | voice_name: Optional[str] = "" 88 | voice_volume: Optional[float] = 1.0 89 | voice_rate: Optional[float] = 1.0 90 | bgm_type: Optional[str] = "random" 91 | bgm_file: Optional[str] = "" 92 | bgm_volume: Optional[float] = 0.2 93 | 94 | subtitle_enabled: Optional[bool] = True 95 | subtitle_position: Optional[str] = "bottom" # top, bottom, center 96 | custom_position: float = 70.0 97 | font_name: Optional[str] = "STHeitiMedium.ttc" 98 | text_fore_color: Optional[str] = "#FFFFFF" 99 | text_background_color: Union[bool, str] = True 100 | 101 | font_size: int = 60 102 | stroke_color: Optional[str] = "#000000" 103 | stroke_width: float = 1.5 104 | n_threads: Optional[int] = 2 105 | paragraph_number: Optional[int] = 1 106 | 107 | 108 | class SubtitleRequest(BaseModel): 109 | video_script: str 110 | video_language: Optional[str] = "" 111 | voice_name: Optional[str] = "zh-CN-XiaoxiaoNeural-Female" 112 | voice_volume: Optional[float] = 1.0 113 | voice_rate: Optional[float] = 1.2 114 | bgm_type: Optional[str] = "random" 115 | bgm_file: Optional[str] = "" 116 | bgm_volume: Optional[float] = 0.2 117 | subtitle_position: Optional[str] = "bottom" 118 | font_name: Optional[str] = "STHeitiMedium.ttc" 119 | text_fore_color: Optional[str] = "#FFFFFF" 120 | text_background_color: Union[bool, str] = True 121 | font_size: int = 60 122 | stroke_color: Optional[str] = "#000000" 123 | stroke_width: float = 1.5 124 | video_source: Optional[str] = "local" 125 | subtitle_enabled: Optional[str] = "true" 126 | 127 | 128 | class AudioRequest(BaseModel): 129 | video_script: str 130 | video_language: Optional[str] = "" 131 | voice_name: Optional[str] = "zh-CN-XiaoxiaoNeural-Female" 132 | voice_volume: Optional[float] = 1.0 133 | voice_rate: Optional[float] = 1.2 134 | bgm_type: Optional[str] = "random" 135 | bgm_file: Optional[str] = "" 136 | bgm_volume: Optional[float] = 0.2 137 | video_source: Optional[str] = "local" 138 | 139 | 140 | class VideoScriptParams: 141 | """ 142 | { 143 | "video_subject": "春天的花海", 144 | "video_language": "", 145 | "paragraph_number": 1 146 | } 147 | """ 148 | 149 | video_subject: Optional[str] = "春天的花海" 150 | video_language: Optional[str] = "" 151 | paragraph_number: Optional[int] = 1 152 | 153 | 154 | class VideoTermsParams: 155 | """ 156 | { 157 | "video_subject": "", 158 | "video_script": "", 159 | "amount": 5 160 | } 161 | """ 162 | 163 | video_subject: Optional[str] = "春天的花海" 164 | video_script: Optional[str] = ( 165 | "春天的花海,如诗如画般展现在眼前。万物复苏的季节里,大地披上了一袭绚丽多彩的盛装。金黄的迎春、粉嫩的樱花、洁白的梨花、艳丽的郁金香……" 166 | ) 167 | amount: Optional[int] = 5 168 | 169 | 170 | class BaseResponse(BaseModel): 171 | status: int = 200 172 | message: Optional[str] = "success" 173 | data: Any = None 174 | 175 | 176 | class TaskVideoRequest(VideoParams, BaseModel): 177 | pass 178 | 179 | 180 | class TaskQueryRequest(BaseModel): 181 | pass 182 | 183 | 184 | class VideoScriptRequest(VideoScriptParams, BaseModel): 185 | pass 186 | 187 | 188 | class VideoTermsRequest(VideoTermsParams, BaseModel): 189 | pass 190 | 191 | 192 | ###################################################################################################### 193 | ###################################################################################################### 194 | ###################################################################################################### 195 | ###################################################################################################### 196 | class TaskResponse(BaseResponse): 197 | class TaskResponseData(BaseModel): 198 | task_id: str 199 | 200 | data: TaskResponseData 201 | 202 | class Config: 203 | json_schema_extra = { 204 | "example": { 205 | "status": 200, 206 | "message": "success", 207 | "data": {"task_id": "6c85c8cc-a77a-42b9-bc30-947815aa0558"}, 208 | }, 209 | } 210 | 211 | 212 | class TaskQueryResponse(BaseResponse): 213 | class Config: 214 | json_schema_extra = { 215 | "example": { 216 | "status": 200, 217 | "message": "success", 218 | "data": { 219 | "state": 1, 220 | "progress": 100, 221 | "videos": [ 222 | "http://127.0.0.1:8080/tasks/6c85c8cc-a77a-42b9-bc30-947815aa0558/final-1.mp4" 223 | ], 224 | "combined_videos": [ 225 | "http://127.0.0.1:8080/tasks/6c85c8cc-a77a-42b9-bc30-947815aa0558/combined-1.mp4" 226 | ], 227 | }, 228 | }, 229 | } 230 | 231 | 232 | class TaskDeletionResponse(BaseResponse): 233 | class Config: 234 | json_schema_extra = { 235 | "example": { 236 | "status": 200, 237 | "message": "success", 238 | "data": { 239 | "state": 1, 240 | "progress": 100, 241 | "videos": [ 242 | "http://127.0.0.1:8080/tasks/6c85c8cc-a77a-42b9-bc30-947815aa0558/final-1.mp4" 243 | ], 244 | "combined_videos": [ 245 | "http://127.0.0.1:8080/tasks/6c85c8cc-a77a-42b9-bc30-947815aa0558/combined-1.mp4" 246 | ], 247 | }, 248 | }, 249 | } 250 | 251 | 252 | class VideoScriptResponse(BaseResponse): 253 | class Config: 254 | json_schema_extra = { 255 | "example": { 256 | "status": 200, 257 | "message": "success", 258 | "data": { 259 | "video_script": "春天的花海,是大自然的一幅美丽画卷。在这个季节里,大地复苏,万物生长,花朵争相绽放,形成了一片五彩斑斓的花海..." 260 | }, 261 | }, 262 | } 263 | 264 | 265 | class VideoTermsResponse(BaseResponse): 266 | class Config: 267 | json_schema_extra = { 268 | "example": { 269 | "status": 200, 270 | "message": "success", 271 | "data": {"video_terms": ["sky", "tree"]}, 272 | }, 273 | } 274 | 275 | 276 | class BgmRetrieveResponse(BaseResponse): 277 | class Config: 278 | json_schema_extra = { 279 | "example": { 280 | "status": 200, 281 | "message": "success", 282 | "data": { 283 | "files": [ 284 | { 285 | "name": "output013.mp3", 286 | "size": 1891269, 287 | "file": "/MoneyPrinterTurbo/resource/songs/output013.mp3", 288 | } 289 | ] 290 | }, 291 | }, 292 | } 293 | 294 | 295 | class BgmUploadResponse(BaseResponse): 296 | class Config: 297 | json_schema_extra = { 298 | "example": { 299 | "status": 200, 300 | "message": "success", 301 | "data": {"file": "/MoneyPrinterTurbo/resource/songs/example.mp3"}, 302 | }, 303 | } 304 | -------------------------------------------------------------------------------- /app/router.py: -------------------------------------------------------------------------------- 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) 18 | -------------------------------------------------------------------------------- /app/services/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/harry0703/MoneyPrinterTurbo/6cb5f2348714b08fd7cf04a416c13faeab1d356b/app/services/__init__.py -------------------------------------------------------------------------------- /app/services/material.py: -------------------------------------------------------------------------------- 1 | import os 2 | import random 3 | from typing import List 4 | from urllib.parse import urlencode 5 | 6 | import requests 7 | from loguru import logger 8 | from moviepy.video.io.VideoFileClip import VideoFileClip 9 | 10 | from app.config import config 11 | from app.models.schema import MaterialInfo, VideoAspect, VideoConcatMode 12 | from app.utils import utils 13 | 14 | requested_count = 0 15 | 16 | 17 | def get_api_key(cfg_key: str): 18 | api_keys = config.app.get(cfg_key) 19 | if not api_keys: 20 | raise ValueError( 21 | f"\n\n##### {cfg_key} is not set #####\n\nPlease set it in the config.toml file: {config.config_file}\n\n" 22 | f"{utils.to_json(config.app)}" 23 | ) 24 | 25 | # if only one key is provided, return it 26 | if isinstance(api_keys, str): 27 | return api_keys 28 | 29 | global requested_count 30 | requested_count += 1 31 | return api_keys[requested_count % len(api_keys)] 32 | 33 | 34 | def search_videos_pexels( 35 | search_term: str, 36 | minimum_duration: int, 37 | video_aspect: VideoAspect = VideoAspect.portrait, 38 | ) -> List[MaterialInfo]: 39 | aspect = VideoAspect(video_aspect) 40 | video_orientation = aspect.name 41 | video_width, video_height = aspect.to_resolution() 42 | api_key = get_api_key("pexels_api_keys") 43 | headers = { 44 | "Authorization": api_key, 45 | "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", 46 | } 47 | # Build URL 48 | params = {"query": search_term, "per_page": 20, "orientation": video_orientation} 49 | query_url = f"https://api.pexels.com/videos/search?{urlencode(params)}" 50 | logger.info(f"searching videos: {query_url}, with proxies: {config.proxy}") 51 | 52 | try: 53 | r = requests.get( 54 | query_url, 55 | headers=headers, 56 | proxies=config.proxy, 57 | verify=False, 58 | timeout=(30, 60), 59 | ) 60 | response = r.json() 61 | video_items = [] 62 | if "videos" not in response: 63 | logger.error(f"search videos failed: {response}") 64 | return video_items 65 | videos = response["videos"] 66 | # loop through each video in the result 67 | for v in videos: 68 | duration = v["duration"] 69 | # check if video has desired minimum duration 70 | if duration < minimum_duration: 71 | continue 72 | video_files = v["video_files"] 73 | # loop through each url to determine the best quality 74 | for video in video_files: 75 | w = int(video["width"]) 76 | h = int(video["height"]) 77 | if w == video_width and h == video_height: 78 | item = MaterialInfo() 79 | item.provider = "pexels" 80 | item.url = video["link"] 81 | item.duration = duration 82 | video_items.append(item) 83 | break 84 | return video_items 85 | except Exception as e: 86 | logger.error(f"search videos failed: {str(e)}") 87 | 88 | return [] 89 | 90 | 91 | def search_videos_pixabay( 92 | search_term: str, 93 | minimum_duration: int, 94 | video_aspect: VideoAspect = VideoAspect.portrait, 95 | ) -> List[MaterialInfo]: 96 | aspect = VideoAspect(video_aspect) 97 | 98 | video_width, video_height = aspect.to_resolution() 99 | 100 | api_key = get_api_key("pixabay_api_keys") 101 | # Build URL 102 | params = { 103 | "q": search_term, 104 | "video_type": "all", # Accepted values: "all", "film", "animation" 105 | "per_page": 50, 106 | "key": api_key, 107 | } 108 | query_url = f"https://pixabay.com/api/videos/?{urlencode(params)}" 109 | logger.info(f"searching videos: {query_url}, with proxies: {config.proxy}") 110 | 111 | try: 112 | r = requests.get( 113 | query_url, proxies=config.proxy, verify=False, timeout=(30, 60) 114 | ) 115 | response = r.json() 116 | video_items = [] 117 | if "hits" not in response: 118 | logger.error(f"search videos failed: {response}") 119 | return video_items 120 | videos = response["hits"] 121 | # loop through each video in the result 122 | for v in videos: 123 | duration = v["duration"] 124 | # check if video has desired minimum duration 125 | if duration < minimum_duration: 126 | continue 127 | video_files = v["videos"] 128 | # loop through each url to determine the best quality 129 | for video_type in video_files: 130 | video = video_files[video_type] 131 | w = int(video["width"]) 132 | # h = int(video["height"]) 133 | if w >= video_width: 134 | item = MaterialInfo() 135 | item.provider = "pixabay" 136 | item.url = video["url"] 137 | item.duration = duration 138 | video_items.append(item) 139 | break 140 | return video_items 141 | except Exception as e: 142 | logger.error(f"search videos failed: {str(e)}") 143 | 144 | return [] 145 | 146 | 147 | def save_video(video_url: str, save_dir: str = "") -> str: 148 | if not save_dir: 149 | save_dir = utils.storage_dir("cache_videos") 150 | 151 | if not os.path.exists(save_dir): 152 | os.makedirs(save_dir) 153 | 154 | url_without_query = video_url.split("?")[0] 155 | url_hash = utils.md5(url_without_query) 156 | video_id = f"vid-{url_hash}" 157 | video_path = f"{save_dir}/{video_id}.mp4" 158 | 159 | # if video already exists, return the path 160 | if os.path.exists(video_path) and os.path.getsize(video_path) > 0: 161 | logger.info(f"video already exists: {video_path}") 162 | return video_path 163 | 164 | headers = { 165 | "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" 166 | } 167 | 168 | # if video does not exist, download it 169 | with open(video_path, "wb") as f: 170 | f.write( 171 | requests.get( 172 | video_url, 173 | headers=headers, 174 | proxies=config.proxy, 175 | verify=False, 176 | timeout=(60, 240), 177 | ).content 178 | ) 179 | 180 | if os.path.exists(video_path) and os.path.getsize(video_path) > 0: 181 | try: 182 | clip = VideoFileClip(video_path) 183 | duration = clip.duration 184 | fps = clip.fps 185 | clip.close() 186 | if duration > 0 and fps > 0: 187 | return video_path 188 | except Exception as e: 189 | try: 190 | os.remove(video_path) 191 | except Exception: 192 | pass 193 | logger.warning(f"invalid video file: {video_path} => {str(e)}") 194 | return "" 195 | 196 | 197 | def download_videos( 198 | task_id: str, 199 | search_terms: List[str], 200 | source: str = "pexels", 201 | video_aspect: VideoAspect = VideoAspect.portrait, 202 | video_contact_mode: VideoConcatMode = VideoConcatMode.random, 203 | audio_duration: float = 0.0, 204 | max_clip_duration: int = 5, 205 | ) -> List[str]: 206 | valid_video_items = [] 207 | valid_video_urls = [] 208 | found_duration = 0.0 209 | search_videos = search_videos_pexels 210 | if source == "pixabay": 211 | search_videos = search_videos_pixabay 212 | 213 | for search_term in search_terms: 214 | video_items = search_videos( 215 | search_term=search_term, 216 | minimum_duration=max_clip_duration, 217 | video_aspect=video_aspect, 218 | ) 219 | logger.info(f"found {len(video_items)} videos for '{search_term}'") 220 | 221 | for item in video_items: 222 | if item.url not in valid_video_urls: 223 | valid_video_items.append(item) 224 | valid_video_urls.append(item.url) 225 | found_duration += item.duration 226 | 227 | logger.info( 228 | f"found total videos: {len(valid_video_items)}, required duration: {audio_duration} seconds, found duration: {found_duration} seconds" 229 | ) 230 | video_paths = [] 231 | 232 | material_directory = config.app.get("material_directory", "").strip() 233 | if material_directory == "task": 234 | material_directory = utils.task_dir(task_id) 235 | elif material_directory and not os.path.isdir(material_directory): 236 | material_directory = "" 237 | 238 | if video_contact_mode.value == VideoConcatMode.random.value: 239 | random.shuffle(valid_video_items) 240 | 241 | total_duration = 0.0 242 | for item in valid_video_items: 243 | try: 244 | logger.info(f"downloading video: {item.url}") 245 | saved_video_path = save_video( 246 | video_url=item.url, save_dir=material_directory 247 | ) 248 | if saved_video_path: 249 | logger.info(f"video saved: {saved_video_path}") 250 | video_paths.append(saved_video_path) 251 | seconds = min(max_clip_duration, item.duration) 252 | total_duration += seconds 253 | if total_duration > audio_duration: 254 | logger.info( 255 | f"total duration of downloaded videos: {total_duration} seconds, skip downloading more" 256 | ) 257 | break 258 | except Exception as e: 259 | logger.error(f"failed to download video: {utils.to_json(item)} => {str(e)}") 260 | logger.success(f"downloaded {len(video_paths)} videos") 261 | return video_paths 262 | 263 | 264 | if __name__ == "__main__": 265 | download_videos( 266 | "test123", ["Money Exchange Medium"], audio_duration=100, source="pixabay" 267 | ) 268 | -------------------------------------------------------------------------------- /app/services/state.py: -------------------------------------------------------------------------------- 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 | total += len(keys) 77 | if total > start: 78 | for key in keys[max(0, start - total):end - total]: 79 | task_data = self._redis.hgetall(key) 80 | task = { 81 | k.decode("utf-8"): self._convert_to_original_type(v) for k, v in task_data.items() 82 | } 83 | tasks.append(task) 84 | if len(tasks) >= page_size: 85 | break 86 | if cursor == 0 or len(tasks) >= page_size: 87 | break 88 | return tasks, total 89 | 90 | def update_task( 91 | self, 92 | task_id: str, 93 | state: int = const.TASK_STATE_PROCESSING, 94 | progress: int = 0, 95 | **kwargs, 96 | ): 97 | progress = int(progress) 98 | if progress > 100: 99 | progress = 100 100 | 101 | fields = { 102 | "task_id": task_id, 103 | "state": state, 104 | "progress": progress, 105 | **kwargs, 106 | } 107 | 108 | for field, value in fields.items(): 109 | self._redis.hset(task_id, field, str(value)) 110 | 111 | def get_task(self, task_id: str): 112 | task_data = self._redis.hgetall(task_id) 113 | if not task_data: 114 | return None 115 | 116 | task = { 117 | key.decode("utf-8"): self._convert_to_original_type(value) 118 | for key, value in task_data.items() 119 | } 120 | return task 121 | 122 | def delete_task(self, task_id: str): 123 | self._redis.delete(task_id) 124 | 125 | @staticmethod 126 | def _convert_to_original_type(value): 127 | """ 128 | Convert the value from byte string to its original data type. 129 | You can extend this method to handle other data types as needed. 130 | """ 131 | value_str = value.decode("utf-8") 132 | 133 | try: 134 | # try to convert byte string array to list 135 | return ast.literal_eval(value_str) 136 | except (ValueError, SyntaxError): 137 | pass 138 | 139 | if value_str.isdigit(): 140 | return int(value_str) 141 | # Add more conversions here if needed 142 | return value_str 143 | 144 | 145 | # Global state 146 | _enable_redis = config.app.get("enable_redis", False) 147 | _redis_host = config.app.get("redis_host", "localhost") 148 | _redis_port = config.app.get("redis_port", 6379) 149 | _redis_db = config.app.get("redis_db", 0) 150 | _redis_password = config.app.get("redis_password", None) 151 | 152 | state = ( 153 | RedisState( 154 | host=_redis_host, port=_redis_port, db=_redis_db, password=_redis_password 155 | ) 156 | if _enable_redis 157 | else MemoryState() 158 | ) 159 | -------------------------------------------------------------------------------- /app/services/subtitle.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os.path 3 | import re 4 | from timeit import default_timer as timer 5 | 6 | from faster_whisper import WhisperModel 7 | from loguru import logger 8 | 9 | from app.config import config 10 | from app.utils import utils 11 | 12 | model_size = config.whisper.get("model_size", "large-v3") 13 | device = config.whisper.get("device", "cpu") 14 | compute_type = config.whisper.get("compute_type", "int8") 15 | model = None 16 | 17 | 18 | def create(audio_file, subtitle_file: str = ""): 19 | global model 20 | if not model: 21 | model_path = f"{utils.root_dir()}/models/whisper-{model_size}" 22 | model_bin_file = f"{model_path}/model.bin" 23 | if not os.path.isdir(model_path) or not os.path.isfile(model_bin_file): 24 | model_path = model_size 25 | 26 | logger.info( 27 | f"loading model: {model_path}, device: {device}, compute_type: {compute_type}" 28 | ) 29 | try: 30 | model = WhisperModel( 31 | model_size_or_path=model_path, device=device, compute_type=compute_type 32 | ) 33 | except Exception as e: 34 | logger.error( 35 | f"failed to load model: {e} \n\n" 36 | f"********************************************\n" 37 | f"this may be caused by network issue. \n" 38 | f"please download the model manually and put it in the 'models' folder. \n" 39 | f"see [README.md FAQ](https://github.com/harry0703/MoneyPrinterTurbo) for more details.\n" 40 | f"********************************************\n\n" 41 | ) 42 | return None 43 | 44 | logger.info(f"start, output file: {subtitle_file}") 45 | if not subtitle_file: 46 | subtitle_file = f"{audio_file}.srt" 47 | 48 | segments, info = model.transcribe( 49 | audio_file, 50 | beam_size=5, 51 | word_timestamps=True, 52 | vad_filter=True, 53 | vad_parameters=dict(min_silence_duration_ms=500), 54 | ) 55 | 56 | logger.info( 57 | f"detected language: '{info.language}', probability: {info.language_probability:.2f}" 58 | ) 59 | 60 | start = timer() 61 | subtitles = [] 62 | 63 | def recognized(seg_text, seg_start, seg_end): 64 | seg_text = seg_text.strip() 65 | if not seg_text: 66 | return 67 | 68 | msg = "[%.2fs -> %.2fs] %s" % (seg_start, seg_end, seg_text) 69 | logger.debug(msg) 70 | 71 | subtitles.append( 72 | {"msg": seg_text, "start_time": seg_start, "end_time": seg_end} 73 | ) 74 | 75 | for segment in segments: 76 | words_idx = 0 77 | words_len = len(segment.words) 78 | 79 | seg_start = 0 80 | seg_end = 0 81 | seg_text = "" 82 | 83 | if segment.words: 84 | is_segmented = False 85 | for word in segment.words: 86 | if not is_segmented: 87 | seg_start = word.start 88 | is_segmented = True 89 | 90 | seg_end = word.end 91 | # If it contains punctuation, then break the sentence. 92 | seg_text += word.word 93 | 94 | if utils.str_contains_punctuation(word.word): 95 | # remove last char 96 | seg_text = seg_text[:-1] 97 | if not seg_text: 98 | continue 99 | 100 | recognized(seg_text, seg_start, seg_end) 101 | 102 | is_segmented = False 103 | seg_text = "" 104 | 105 | if words_idx == 0 and segment.start < word.start: 106 | seg_start = word.start 107 | if words_idx == (words_len - 1) and segment.end > word.end: 108 | seg_end = word.end 109 | words_idx += 1 110 | 111 | if not seg_text: 112 | continue 113 | 114 | recognized(seg_text, seg_start, seg_end) 115 | 116 | end = timer() 117 | 118 | diff = end - start 119 | logger.info(f"complete, elapsed: {diff:.2f} s") 120 | 121 | idx = 1 122 | lines = [] 123 | for subtitle in subtitles: 124 | text = subtitle.get("msg") 125 | if text: 126 | lines.append( 127 | utils.text_to_srt( 128 | idx, text, subtitle.get("start_time"), subtitle.get("end_time") 129 | ) 130 | ) 131 | idx += 1 132 | 133 | sub = "\n".join(lines) + "\n" 134 | with open(subtitle_file, "w", encoding="utf-8") as f: 135 | f.write(sub) 136 | logger.info(f"subtitle file created: {subtitle_file}") 137 | 138 | 139 | def file_to_subtitles(filename): 140 | if not filename or not os.path.isfile(filename): 141 | return [] 142 | 143 | times_texts = [] 144 | current_times = None 145 | current_text = "" 146 | index = 0 147 | with open(filename, "r", encoding="utf-8") as f: 148 | for line in f: 149 | times = re.findall("([0-9]*:[0-9]*:[0-9]*,[0-9]*)", line) 150 | if times: 151 | current_times = line 152 | elif line.strip() == "" and current_times: 153 | index += 1 154 | times_texts.append((index, current_times.strip(), current_text.strip())) 155 | current_times, current_text = None, "" 156 | elif current_times: 157 | current_text += line 158 | return times_texts 159 | 160 | 161 | def levenshtein_distance(s1, s2): 162 | if len(s1) < len(s2): 163 | return levenshtein_distance(s2, s1) 164 | 165 | if len(s2) == 0: 166 | return len(s1) 167 | 168 | previous_row = range(len(s2) + 1) 169 | for i, c1 in enumerate(s1): 170 | current_row = [i + 1] 171 | for j, c2 in enumerate(s2): 172 | insertions = previous_row[j + 1] + 1 173 | deletions = current_row[j] + 1 174 | substitutions = previous_row[j] + (c1 != c2) 175 | current_row.append(min(insertions, deletions, substitutions)) 176 | previous_row = current_row 177 | 178 | return previous_row[-1] 179 | 180 | 181 | def similarity(a, b): 182 | distance = levenshtein_distance(a.lower(), b.lower()) 183 | max_length = max(len(a), len(b)) 184 | return 1 - (distance / max_length) 185 | 186 | 187 | def correct(subtitle_file, video_script): 188 | subtitle_items = file_to_subtitles(subtitle_file) 189 | script_lines = utils.split_string_by_punctuations(video_script) 190 | 191 | corrected = False 192 | new_subtitle_items = [] 193 | script_index = 0 194 | subtitle_index = 0 195 | 196 | while script_index < len(script_lines) and subtitle_index < len(subtitle_items): 197 | script_line = script_lines[script_index].strip() 198 | subtitle_line = subtitle_items[subtitle_index][2].strip() 199 | 200 | if script_line == subtitle_line: 201 | new_subtitle_items.append(subtitle_items[subtitle_index]) 202 | script_index += 1 203 | subtitle_index += 1 204 | else: 205 | combined_subtitle = subtitle_line 206 | start_time = subtitle_items[subtitle_index][1].split(" --> ")[0] 207 | end_time = subtitle_items[subtitle_index][1].split(" --> ")[1] 208 | next_subtitle_index = subtitle_index + 1 209 | 210 | while next_subtitle_index < len(subtitle_items): 211 | next_subtitle = subtitle_items[next_subtitle_index][2].strip() 212 | if similarity( 213 | script_line, combined_subtitle + " " + next_subtitle 214 | ) > similarity(script_line, combined_subtitle): 215 | combined_subtitle += " " + next_subtitle 216 | end_time = subtitle_items[next_subtitle_index][1].split(" --> ")[1] 217 | next_subtitle_index += 1 218 | else: 219 | break 220 | 221 | if similarity(script_line, combined_subtitle) > 0.8: 222 | logger.warning( 223 | f"Merged/Corrected - Script: {script_line}, Subtitle: {combined_subtitle}" 224 | ) 225 | new_subtitle_items.append( 226 | ( 227 | len(new_subtitle_items) + 1, 228 | f"{start_time} --> {end_time}", 229 | script_line, 230 | ) 231 | ) 232 | corrected = True 233 | else: 234 | logger.warning( 235 | f"Mismatch - Script: {script_line}, Subtitle: {combined_subtitle}" 236 | ) 237 | new_subtitle_items.append( 238 | ( 239 | len(new_subtitle_items) + 1, 240 | f"{start_time} --> {end_time}", 241 | script_line, 242 | ) 243 | ) 244 | corrected = True 245 | 246 | script_index += 1 247 | subtitle_index = next_subtitle_index 248 | 249 | # Process the remaining lines of the script. 250 | while script_index < len(script_lines): 251 | logger.warning(f"Extra script line: {script_lines[script_index]}") 252 | if subtitle_index < len(subtitle_items): 253 | new_subtitle_items.append( 254 | ( 255 | len(new_subtitle_items) + 1, 256 | subtitle_items[subtitle_index][1], 257 | script_lines[script_index], 258 | ) 259 | ) 260 | subtitle_index += 1 261 | else: 262 | new_subtitle_items.append( 263 | ( 264 | len(new_subtitle_items) + 1, 265 | "00:00:00,000 --> 00:00:00,000", 266 | script_lines[script_index], 267 | ) 268 | ) 269 | script_index += 1 270 | corrected = True 271 | 272 | if corrected: 273 | with open(subtitle_file, "w", encoding="utf-8") as fd: 274 | for i, item in enumerate(new_subtitle_items): 275 | fd.write(f"{i + 1}\n{item[1]}\n{item[2]}\n\n") 276 | logger.info("Subtitle corrected") 277 | else: 278 | logger.success("Subtitle is correct") 279 | 280 | 281 | if __name__ == "__main__": 282 | task_id = "c12fd1e6-4b0a-4d65-a075-c87abe35a072" 283 | task_dir = utils.task_dir(task_id) 284 | subtitle_file = f"{task_dir}/subtitle.srt" 285 | audio_file = f"{task_dir}/audio.mp3" 286 | 287 | subtitles = file_to_subtitles(subtitle_file) 288 | print(subtitles) 289 | 290 | script_file = f"{task_dir}/script.json" 291 | with open(script_file, "r") as f: 292 | script_content = f.read() 293 | s = json.loads(script_content) 294 | script = s.get("script") 295 | 296 | correct(subtitle_file, script) 297 | 298 | subtitle_file = f"{task_dir}/subtitle-test.srt" 299 | create(audio_file, subtitle_file) 300 | -------------------------------------------------------------------------------- /app/services/task.py: -------------------------------------------------------------------------------- 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 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 | ) 25 | else: 26 | logger.debug(f"video script: \n{video_script}") 27 | 28 | if not video_script: 29 | sm.state.update_task(task_id, state=const.TASK_STATE_FAILED) 30 | logger.error("failed to generate video script.") 31 | return None 32 | 33 | return video_script 34 | 35 | 36 | def generate_terms(task_id, params, video_script): 37 | logger.info("\n\n## generating video terms") 38 | video_terms = params.video_terms 39 | if not video_terms: 40 | video_terms = llm.generate_terms( 41 | video_subject=params.video_subject, video_script=video_script, amount=5 42 | ) 43 | else: 44 | if isinstance(video_terms, str): 45 | video_terms = [term.strip() for term in re.split(r"[,,]", video_terms)] 46 | elif isinstance(video_terms, list): 47 | video_terms = [term.strip() for term in video_terms] 48 | else: 49 | raise ValueError("video_terms must be a string or a list of strings.") 50 | 51 | logger.debug(f"video terms: {utils.to_json(video_terms)}") 52 | 53 | if not video_terms: 54 | sm.state.update_task(task_id, state=const.TASK_STATE_FAILED) 55 | logger.error("failed to generate video terms.") 56 | return None 57 | 58 | return video_terms 59 | 60 | 61 | def save_script_data(task_id, video_script, video_terms, params): 62 | script_file = path.join(utils.task_dir(task_id), "script.json") 63 | script_data = { 64 | "script": video_script, 65 | "search_terms": video_terms, 66 | "params": params, 67 | } 68 | 69 | with open(script_file, "w", encoding="utf-8") as f: 70 | f.write(utils.to_json(script_data)) 71 | 72 | 73 | def generate_audio(task_id, params, video_script): 74 | logger.info("\n\n## generating audio") 75 | audio_file = path.join(utils.task_dir(task_id), "audio.mp3") 76 | sub_maker = voice.tts( 77 | text=video_script, 78 | voice_name=voice.parse_voice_name(params.voice_name), 79 | voice_rate=params.voice_rate, 80 | voice_file=audio_file, 81 | ) 82 | if sub_maker is None: 83 | sm.state.update_task(task_id, state=const.TASK_STATE_FAILED) 84 | logger.error( 85 | """failed to generate audio: 86 | 1. check if the language of the voice matches the language of the video script. 87 | 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. 88 | """.strip() 89 | ) 90 | return None, None, None 91 | 92 | audio_duration = math.ceil(voice.get_audio_duration(sub_maker)) 93 | return audio_file, audio_duration, sub_maker 94 | 95 | 96 | def generate_subtitle(task_id, params, video_script, sub_maker, audio_file): 97 | if not params.subtitle_enabled: 98 | return "" 99 | 100 | subtitle_path = path.join(utils.task_dir(task_id), "subtitle.srt") 101 | subtitle_provider = config.app.get("subtitle_provider", "edge").strip().lower() 102 | logger.info(f"\n\n## generating subtitle, provider: {subtitle_provider}") 103 | 104 | subtitle_fallback = False 105 | if subtitle_provider == "edge": 106 | voice.create_subtitle( 107 | text=video_script, sub_maker=sub_maker, subtitle_file=subtitle_path 108 | ) 109 | if not os.path.exists(subtitle_path): 110 | subtitle_fallback = True 111 | logger.warning("subtitle file not found, fallback to whisper") 112 | 113 | if subtitle_provider == "whisper" or subtitle_fallback: 114 | subtitle.create(audio_file=audio_file, subtitle_file=subtitle_path) 115 | logger.info("\n\n## correcting subtitle") 116 | subtitle.correct(subtitle_file=subtitle_path, video_script=video_script) 117 | 118 | subtitle_lines = subtitle.file_to_subtitles(subtitle_path) 119 | if not subtitle_lines: 120 | logger.warning(f"subtitle file is invalid: {subtitle_path}") 121 | return "" 122 | 123 | return subtitle_path 124 | 125 | 126 | def get_video_materials(task_id, params, video_terms, audio_duration): 127 | if params.video_source == "local": 128 | logger.info("\n\n## preprocess local materials") 129 | materials = video.preprocess_video( 130 | materials=params.video_materials, clip_duration=params.video_clip_duration 131 | ) 132 | if not materials: 133 | sm.state.update_task(task_id, state=const.TASK_STATE_FAILED) 134 | logger.error( 135 | "no valid materials found, please check the materials and try again." 136 | ) 137 | return None 138 | return [material_info.url for material_info in materials] 139 | else: 140 | logger.info(f"\n\n## downloading videos from {params.video_source}") 141 | downloaded_videos = material.download_videos( 142 | task_id=task_id, 143 | search_terms=video_terms, 144 | source=params.video_source, 145 | video_aspect=params.video_aspect, 146 | video_contact_mode=params.video_concat_mode, 147 | audio_duration=audio_duration * params.video_count, 148 | max_clip_duration=params.video_clip_duration, 149 | ) 150 | if not downloaded_videos: 151 | sm.state.update_task(task_id, state=const.TASK_STATE_FAILED) 152 | logger.error( 153 | "failed to download videos, maybe the network is not available. if you are in China, please use a VPN." 154 | ) 155 | return None 156 | return downloaded_videos 157 | 158 | 159 | def generate_final_videos( 160 | task_id, params, downloaded_videos, audio_file, subtitle_path 161 | ): 162 | final_video_paths = [] 163 | combined_video_paths = [] 164 | video_concat_mode = ( 165 | params.video_concat_mode if params.video_count == 1 else VideoConcatMode.random 166 | ) 167 | video_transition_mode = params.video_transition_mode 168 | 169 | _progress = 50 170 | for i in range(params.video_count): 171 | index = i + 1 172 | combined_video_path = path.join( 173 | utils.task_dir(task_id), f"combined-{index}.mp4" 174 | ) 175 | logger.info(f"\n\n## combining video: {index} => {combined_video_path}") 176 | video.combine_videos( 177 | combined_video_path=combined_video_path, 178 | video_paths=downloaded_videos, 179 | audio_file=audio_file, 180 | video_aspect=params.video_aspect, 181 | video_concat_mode=video_concat_mode, 182 | video_transition_mode=video_transition_mode, 183 | max_clip_duration=params.video_clip_duration, 184 | threads=params.n_threads, 185 | ) 186 | 187 | _progress += 50 / params.video_count / 2 188 | sm.state.update_task(task_id, progress=_progress) 189 | 190 | final_video_path = path.join(utils.task_dir(task_id), f"final-{index}.mp4") 191 | 192 | logger.info(f"\n\n## generating video: {index} => {final_video_path}") 193 | video.generate_video( 194 | video_path=combined_video_path, 195 | audio_path=audio_file, 196 | subtitle_path=subtitle_path, 197 | output_file=final_video_path, 198 | params=params, 199 | ) 200 | 201 | _progress += 50 / params.video_count / 2 202 | sm.state.update_task(task_id, progress=_progress) 203 | 204 | final_video_paths.append(final_video_path) 205 | combined_video_paths.append(combined_video_path) 206 | 207 | return final_video_paths, combined_video_paths 208 | 209 | 210 | def start(task_id, params: VideoParams, stop_at: str = "video"): 211 | logger.info(f"start task: {task_id}, stop_at: {stop_at}") 212 | sm.state.update_task(task_id, state=const.TASK_STATE_PROCESSING, progress=5) 213 | 214 | if type(params.video_concat_mode) is str: 215 | params.video_concat_mode = VideoConcatMode(params.video_concat_mode) 216 | 217 | # 1. Generate script 218 | video_script = generate_script(task_id, params) 219 | if not video_script or "Error: " in video_script: 220 | sm.state.update_task(task_id, state=const.TASK_STATE_FAILED) 221 | return 222 | 223 | sm.state.update_task(task_id, state=const.TASK_STATE_PROCESSING, progress=10) 224 | 225 | if stop_at == "script": 226 | sm.state.update_task( 227 | task_id, state=const.TASK_STATE_COMPLETE, progress=100, script=video_script 228 | ) 229 | return {"script": video_script} 230 | 231 | # 2. Generate terms 232 | video_terms = "" 233 | if params.video_source != "local": 234 | video_terms = generate_terms(task_id, params, video_script) 235 | if not video_terms: 236 | sm.state.update_task(task_id, state=const.TASK_STATE_FAILED) 237 | return 238 | 239 | save_script_data(task_id, video_script, video_terms, params) 240 | 241 | if stop_at == "terms": 242 | sm.state.update_task( 243 | task_id, state=const.TASK_STATE_COMPLETE, progress=100, terms=video_terms 244 | ) 245 | return {"script": video_script, "terms": video_terms} 246 | 247 | sm.state.update_task(task_id, state=const.TASK_STATE_PROCESSING, progress=20) 248 | 249 | # 3. Generate audio 250 | audio_file, audio_duration, sub_maker = generate_audio( 251 | task_id, params, video_script 252 | ) 253 | if not audio_file: 254 | sm.state.update_task(task_id, state=const.TASK_STATE_FAILED) 255 | return 256 | 257 | sm.state.update_task(task_id, state=const.TASK_STATE_PROCESSING, progress=30) 258 | 259 | if stop_at == "audio": 260 | sm.state.update_task( 261 | task_id, 262 | state=const.TASK_STATE_COMPLETE, 263 | progress=100, 264 | audio_file=audio_file, 265 | ) 266 | return {"audio_file": audio_file, "audio_duration": audio_duration} 267 | 268 | # 4. Generate subtitle 269 | subtitle_path = generate_subtitle( 270 | task_id, params, video_script, sub_maker, audio_file 271 | ) 272 | 273 | if stop_at == "subtitle": 274 | sm.state.update_task( 275 | task_id, 276 | state=const.TASK_STATE_COMPLETE, 277 | progress=100, 278 | subtitle_path=subtitle_path, 279 | ) 280 | return {"subtitle_path": subtitle_path} 281 | 282 | sm.state.update_task(task_id, state=const.TASK_STATE_PROCESSING, progress=40) 283 | 284 | # 5. Get video materials 285 | downloaded_videos = get_video_materials( 286 | task_id, params, video_terms, audio_duration 287 | ) 288 | if not downloaded_videos: 289 | sm.state.update_task(task_id, state=const.TASK_STATE_FAILED) 290 | return 291 | 292 | if stop_at == "materials": 293 | sm.state.update_task( 294 | task_id, 295 | state=const.TASK_STATE_COMPLETE, 296 | progress=100, 297 | materials=downloaded_videos, 298 | ) 299 | return {"materials": downloaded_videos} 300 | 301 | sm.state.update_task(task_id, state=const.TASK_STATE_PROCESSING, progress=50) 302 | 303 | # 6. Generate final videos 304 | final_video_paths, combined_video_paths = generate_final_videos( 305 | task_id, params, downloaded_videos, audio_file, subtitle_path 306 | ) 307 | 308 | if not final_video_paths: 309 | sm.state.update_task(task_id, state=const.TASK_STATE_FAILED) 310 | return 311 | 312 | logger.success( 313 | f"task {task_id} finished, generated {len(final_video_paths)} videos." 314 | ) 315 | 316 | kwargs = { 317 | "videos": final_video_paths, 318 | "combined_videos": combined_video_paths, 319 | "script": video_script, 320 | "terms": video_terms, 321 | "audio_file": audio_file, 322 | "audio_duration": audio_duration, 323 | "subtitle_path": subtitle_path, 324 | "materials": downloaded_videos, 325 | } 326 | sm.state.update_task( 327 | task_id, state=const.TASK_STATE_COMPLETE, progress=100, **kwargs 328 | ) 329 | return kwargs 330 | 331 | 332 | if __name__ == "__main__": 333 | task_id = "task_id" 334 | params = VideoParams( 335 | video_subject="金钱的作用", 336 | voice_name="zh-CN-XiaoyiNeural-Female", 337 | voice_rate=1.0, 338 | ) 339 | start(task_id, params, stop_at="video") 340 | -------------------------------------------------------------------------------- /app/services/utils/video_effects.py: -------------------------------------------------------------------------------- 1 | from moviepy import Clip, 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 | return clip.with_effects([vfx.SlideIn(t, side)]) 17 | 18 | 19 | # SlideOut 20 | def slideout_transition(clip: Clip, t: float, side: str) -> Clip: 21 | return clip.with_effects([vfx.SlideOut(t, side)]) 22 | -------------------------------------------------------------------------------- /app/utils/utils.py: -------------------------------------------------------------------------------- 1 | import json 2 | import locale 3 | import os 4 | from pathlib import Path 5 | import threading 6 | from typing import Any 7 | from uuid import uuid4 8 | 9 | import urllib3 10 | from loguru import logger 11 | 12 | from app.models import const 13 | 14 | urllib3.disable_warnings() 15 | 16 | 17 | def get_response(status: int, data: Any = None, message: str = ""): 18 | obj = { 19 | "status": status, 20 | } 21 | if data: 22 | obj["data"] = data 23 | if message: 24 | obj["message"] = message 25 | return obj 26 | 27 | 28 | def to_json(obj): 29 | try: 30 | # Define a helper function to handle different types of objects 31 | def serialize(o): 32 | # If the object is a serializable type, return it directly 33 | if isinstance(o, (int, float, bool, str)) or o is None: 34 | return o 35 | # If the object is binary data, convert it to a base64-encoded string 36 | elif isinstance(o, bytes): 37 | return "*** binary data ***" 38 | # If the object is a dictionary, recursively process each key-value pair 39 | elif isinstance(o, dict): 40 | return {k: serialize(v) for k, v in o.items()} 41 | # If the object is a list or tuple, recursively process each element 42 | elif isinstance(o, (list, tuple)): 43 | return [serialize(item) for item in o] 44 | # If the object is a custom type, attempt to return its __dict__ attribute 45 | elif hasattr(o, "__dict__"): 46 | return serialize(o.__dict__) 47 | # Return None for other cases (or choose to raise an exception) 48 | else: 49 | return None 50 | 51 | # Use the serialize function to process the input object 52 | serialized_obj = serialize(obj) 53 | 54 | # Serialize the processed object into a JSON string 55 | return json.dumps(serialized_obj, ensure_ascii=False, indent=4) 56 | except Exception: 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}") 130 | 131 | thread = threading.Thread(target=run) 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 not in const.PUNCTUATIONS: 191 | txt += char 192 | else: 193 | result.append(txt.strip()) 194 | txt = "" 195 | result.append(txt.strip()) 196 | # filter empty string 197 | result = list(filter(None, result)) 198 | return result 199 | 200 | 201 | def md5(text): 202 | import hashlib 203 | 204 | return hashlib.md5(text.encode("utf-8")).hexdigest() 205 | 206 | 207 | def get_system_locale(): 208 | try: 209 | loc = locale.getdefaultlocale() 210 | # zh_CN, zh_TW return zh 211 | # en_US, en_GB return en 212 | language_code = loc[0].split("_")[0] 213 | return language_code 214 | except Exception: 215 | return "en" 216 | 217 | 218 | def load_locales(i18n_dir): 219 | _locales = {} 220 | for root, dirs, files in os.walk(i18n_dir): 221 | for file in files: 222 | if file.endswith(".json"): 223 | lang = file.split(".")[0] 224 | with open(os.path.join(root, file), "r", encoding="utf-8") as f: 225 | _locales[lang] = json.loads(f.read()) 226 | return _locales 227 | 228 | 229 | def parse_extension(filename): 230 | return Path(filename).suffix.lower().lstrip('.') 231 | -------------------------------------------------------------------------------- /config.example.toml: -------------------------------------------------------------------------------- 1 | [app] 2 | video_source = "pexels" # "pexels" or "pixabay" 3 | 4 | # 是否隐藏配置面板 5 | hide_config = false 6 | 7 | # Pexels API Key 8 | # Register at https://www.pexels.com/api/ to get your API key. 9 | # You can use multiple keys to avoid rate limits. 10 | # For example: pexels_api_keys = ["123adsf4567adf89","abd1321cd13efgfdfhi"] 11 | # 特别注意格式,Key 用英文双引号括起来,多个Key用逗号隔开 12 | pexels_api_keys = [] 13 | 14 | # Pixabay API Key 15 | # Register at https://pixabay.com/api/docs/ to get your API key. 16 | # You can use multiple keys to avoid rate limits. 17 | # For example: pixabay_api_keys = ["123adsf4567adf89","abd1321cd13efgfdfhi"] 18 | # 特别注意格式,Key 用英文双引号括起来,多个Key用逗号隔开 19 | pixabay_api_keys = [] 20 | 21 | # 支持的提供商 (Supported providers): 22 | # openai 23 | # moonshot (月之暗面) 24 | # azure 25 | # qwen (通义千问) 26 | # deepseek 27 | # gemini 28 | # ollama 29 | # g4f 30 | # oneapi 31 | # cloudflare 32 | # ernie (文心一言) 33 | llm_provider = "openai" 34 | 35 | ########## Pollinations AI Settings 36 | # Visit https://pollinations.ai/ to learn more 37 | # API Key is optional - leave empty for public access 38 | pollinations_api_key = "" 39 | # Default base URL for Pollinations API 40 | pollinations_base_url = "https://pollinations.ai/api/v1" 41 | # Default model for text generation 42 | pollinations_model_name = "openai-fast" 43 | 44 | ########## Ollama Settings 45 | # No need to set it unless you want to use your own proxy 46 | ollama_base_url = "" 47 | # Check your available models at https://ollama.com/library 48 | ollama_model_name = "" 49 | 50 | ########## OpenAI API Key 51 | # Get your API key at https://platform.openai.com/api-keys 52 | openai_api_key = "" 53 | # No need to set it unless you want to use your own proxy 54 | openai_base_url = "" 55 | # Check your available models at https://platform.openai.com/account/limits 56 | openai_model_name = "gpt-4o-mini" 57 | 58 | ########## Moonshot API Key 59 | # Visit https://platform.moonshot.cn/console/api-keys to get your API key. 60 | moonshot_api_key = "" 61 | moonshot_base_url = "https://api.moonshot.cn/v1" 62 | moonshot_model_name = "moonshot-v1-8k" 63 | 64 | ########## OneAPI API Key 65 | # Visit https://github.com/songquanpeng/one-api to get your API key 66 | oneapi_api_key = "" 67 | oneapi_base_url = "" 68 | oneapi_model_name = "" 69 | 70 | ########## G4F 71 | # Visit https://github.com/xtekky/gpt4free to get more details 72 | # Supported model list: https://github.com/xtekky/gpt4free/blob/main/g4f/models.py 73 | g4f_model_name = "gpt-3.5-turbo" 74 | 75 | ########## Azure API Key 76 | # Visit https://learn.microsoft.com/zh-cn/azure/ai-services/openai/ to get more details 77 | # API documentation: https://learn.microsoft.com/zh-cn/azure/ai-services/openai/reference 78 | azure_api_key = "" 79 | azure_base_url = "" 80 | azure_model_name = "gpt-35-turbo" # replace with your model deployment name 81 | azure_api_version = "2024-02-15-preview" 82 | 83 | ########## Gemini API Key 84 | gemini_api_key = "" 85 | gemini_model_name = "gemini-1.0-pro" 86 | 87 | ########## Qwen API Key 88 | # Visit https://dashscope.console.aliyun.com/apiKey to get your API key 89 | # Visit below links to get more details 90 | # https://tongyi.aliyun.com/qianwen/ 91 | # https://help.aliyun.com/zh/dashscope/developer-reference/model-introduction 92 | qwen_api_key = "" 93 | qwen_model_name = "qwen-max" 94 | 95 | 96 | ########## DeepSeek API Key 97 | # Visit https://platform.deepseek.com/api_keys to get your API key 98 | deepseek_api_key = "" 99 | deepseek_base_url = "https://api.deepseek.com" 100 | deepseek_model_name = "deepseek-chat" 101 | 102 | # Subtitle Provider, "edge" or "whisper" 103 | # If empty, the subtitle will not be generated 104 | subtitle_provider = "edge" 105 | 106 | # 107 | # ImageMagick 108 | # 109 | # Once you have installed it, ImageMagick will be automatically detected, except on Windows! 110 | # On Windows, for example "C:\Program Files (x86)\ImageMagick-7.1.1-Q16-HDRI\magick.exe" 111 | # Download from https://imagemagick.org/archive/binaries/ImageMagick-7.1.1-29-Q16-x64-static.exe 112 | 113 | # imagemagick_path = "C:\\Program Files (x86)\\ImageMagick-7.1.1-Q16\\magick.exe" 114 | 115 | 116 | # 117 | # FFMPEG 118 | # 119 | # 通常情况下,ffmpeg 会被自动下载,并且会被自动检测到。 120 | # 但是如果你的环境有问题,无法自动下载,可能会遇到如下错误: 121 | # RuntimeError: No ffmpeg exe could be found. 122 | # Install ffmpeg on your system, or set the IMAGEIO_FFMPEG_EXE environment variable. 123 | # 此时你可以手动下载 ffmpeg 并设置 ffmpeg_path,下载地址:https://www.gyan.dev/ffmpeg/builds/ 124 | 125 | # Under normal circumstances, ffmpeg is downloaded automatically and detected automatically. 126 | # However, if there is an issue with your environment that prevents automatic downloading, you might encounter the following error: 127 | # RuntimeError: No ffmpeg exe could be found. 128 | # Install ffmpeg on your system, or set the IMAGEIO_FFMPEG_EXE environment variable. 129 | # In such cases, you can manually download ffmpeg and set the ffmpeg_path, download link: https://www.gyan.dev/ffmpeg/builds/ 130 | 131 | # ffmpeg_path = "C:\\Users\\harry\\Downloads\\ffmpeg.exe" 132 | ######################################################################################### 133 | 134 | # 当视频生成成功后,API服务提供的视频下载接入点,默认为当前服务的地址和监听端口 135 | # 比如 http://127.0.0.1:8080/tasks/6357f542-a4e1-46a1-b4c9-bf3bd0df5285/final-1.mp4 136 | # 如果你需要使用域名对外提供服务(一般会用nginx做代理),则可以设置为你的域名 137 | # 比如 https://xxxx.com/tasks/6357f542-a4e1-46a1-b4c9-bf3bd0df5285/final-1.mp4 138 | # endpoint="https://xxxx.com" 139 | 140 | # 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. 141 | # For example, http://127.0.0.1:8080/tasks/6357f542-a4e1-46a1-b4c9-bf3bd0df5285/final-1.mp4 142 | # 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. 143 | # For example, https://xxxx.com/tasks/6357f542-a4e1-46a1-b4c9-bf3bd0df5285/final-1.mp4 144 | # endpoint="https://xxxx.com" 145 | endpoint = "" 146 | 147 | 148 | # Video material storage location 149 | # material_directory = "" # Indicates that video materials will be downloaded to the default folder, the default folder is ./storage/cache_videos under the current project 150 | # material_directory = "/user/harry/videos" # Indicates that video materials will be downloaded to a specified folder 151 | # 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 152 | 153 | # 视频素材存放位置 154 | # material_directory = "" #表示将视频素材下载到默认的文件夹,默认文件夹为当前项目下的 ./storage/cache_videos 155 | # material_directory = "/user/harry/videos" #表示将视频素材下载到指定的文件夹中 156 | # material_directory = "task" #表示将视频素材下载到当前任务的文件夹中,这种方式无法共享已经下载的视频素材 157 | 158 | material_directory = "" 159 | 160 | # Used for state management of the task 161 | enable_redis = false 162 | redis_host = "localhost" 163 | redis_port = 6379 164 | redis_db = 0 165 | redis_password = "" 166 | 167 | # 文生视频时的最大并发任务数 168 | max_concurrent_tasks = 5 169 | 170 | 171 | [whisper] 172 | # Only effective when subtitle_provider is "whisper" 173 | 174 | # Run on GPU with FP16 175 | # model = WhisperModel(model_size, device="cuda", compute_type="float16") 176 | 177 | # Run on GPU with INT8 178 | # model = WhisperModel(model_size, device="cuda", compute_type="int8_float16") 179 | 180 | # Run on CPU with INT8 181 | # model = WhisperModel(model_size, device="cpu", compute_type="int8") 182 | 183 | # recommended model_size: "large-v3" 184 | model_size = "large-v3" 185 | # if you want to use GPU, set device="cuda" 186 | device = "CPU" 187 | compute_type = "int8" 188 | 189 | 190 | [proxy] 191 | ### Use a proxy to access the Pexels API 192 | ### Format: "http://<username>:<password>@<proxy>:<port>" 193 | ### Example: "http://user:pass@proxy:1234" 194 | ### Doc: https://requests.readthedocs.io/en/latest/user/advanced/#proxies 195 | 196 | # http = "http://10.10.1.10:3128" 197 | # https = "http://10.10.1.10:1080" 198 | 199 | [azure] 200 | # Azure Speech API Key 201 | # Get your API key at https://portal.azure.com/#view/Microsoft_Azure_ProjectOxford/CognitiveServicesHub/~/SpeechServices 202 | speech_key = "" 203 | speech_region = "" 204 | 205 | [siliconflow] 206 | # SiliconFlow API Key 207 | # Get your API key at https://siliconflow.cn 208 | api_key = "" 209 | 210 | [ui] 211 | # UI related settings 212 | # 是否隐藏日志信息 213 | # Whether to hide logs in the UI 214 | hide_log = false 215 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 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 -------------------------------------------------------------------------------- /docs/MoneyPrinterTurbo.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "metadata": {}, 6 | "source": [ 7 | "# MoneyPrinterTurbo Setup Guide\n", 8 | "\n", 9 | "This notebook will guide you through the process of setting up [MoneyPrinterTurbo](https://github.com/harry0703/MoneyPrinterTurbo)." 10 | ] 11 | }, 12 | { 13 | "cell_type": "markdown", 14 | "metadata": {}, 15 | "source": [ 16 | "## 1. Clone Repository and Install Dependencies\n", 17 | "\n", 18 | "First, we'll clone the repository from GitHub and install all required packages:" 19 | ] 20 | }, 21 | { 22 | "cell_type": "code", 23 | "execution_count": null, 24 | "metadata": { 25 | "id": "S8Eu-aQarY_B" 26 | }, 27 | "outputs": [], 28 | "source": [ 29 | "!git clone https://github.com/harry0703/MoneyPrinterTurbo.git\n", 30 | "%cd MoneyPrinterTurbo\n", 31 | "!pip install -q -r requirements.txt\n", 32 | "!pip install pyngrok --quiet" 33 | ] 34 | }, 35 | { 36 | "cell_type": "markdown", 37 | "metadata": {}, 38 | "source": [ 39 | "## 2. Configure ngrok for Remote Access\n", 40 | "\n", 41 | "We'll use ngrok to create a secure tunnel to expose our local Streamlit server to the internet.\n", 42 | "\n", 43 | "**Important**: You need to get your authentication token from the [ngrok dashboard](https://dashboard.ngrok.com/get-started/your-authtoken) to use this service." 44 | ] 45 | }, 46 | { 47 | "cell_type": "code", 48 | "execution_count": null, 49 | "metadata": {}, 50 | "outputs": [], 51 | "source": [ 52 | "from pyngrok import ngrok\n", 53 | "\n", 54 | "# Terminate any existing ngrok tunnels\n", 55 | "ngrok.kill()\n", 56 | "\n", 57 | "# Set your authentication token\n", 58 | "# Replace \"your_ngrok_auth_token\" with your actual token\n", 59 | "ngrok.set_auth_token(\"your_ngrok_auth_token\")" 60 | ] 61 | }, 62 | { 63 | "cell_type": "markdown", 64 | "metadata": {}, 65 | "source": [ 66 | "## 3. Launch Application and Generate Public URL\n", 67 | "\n", 68 | "Now we'll start the Streamlit server and create an ngrok tunnel to make it accessible online:" 69 | ] 70 | }, 71 | { 72 | "cell_type": "code", 73 | "execution_count": null, 74 | "metadata": { 75 | "colab": { 76 | "base_uri": "https://localhost:8080/" 77 | }, 78 | "collapsed": true, 79 | "id": "oahsIOXmwjl9", 80 | "outputId": "ee23a96c-af21-4207-deb7-9fab69e0c05e" 81 | }, 82 | "outputs": [], 83 | "source": [ 84 | "import subprocess\n", 85 | "import time\n", 86 | "\n", 87 | "print(\"🚀 Starting MoneyPrinterTurbo...\")\n", 88 | "# Start Streamlit server on port 8501\n", 89 | "streamlit_proc = subprocess.Popen([\n", 90 | " \"streamlit\", \"run\", \"./webui/Main.py\", \"--server.port=8501\"\n", 91 | "])\n", 92 | "\n", 93 | "# Wait for the server to initialize\n", 94 | "time.sleep(5)\n", 95 | "\n", 96 | "print(\"🌐 Creating ngrok tunnel to expose the MoneyPrinterTurbo...\")\n", 97 | "public_url = ngrok.connect(8501, bind_tls=True)\n", 98 | "\n", 99 | "print(\"✅ Deployment complete! Access your MoneyPrinterTurbo at:\")\n", 100 | "print(public_url)" 101 | ] 102 | } 103 | ], 104 | "metadata": { 105 | "colab": { 106 | "provenance": [] 107 | }, 108 | "kernelspec": { 109 | "display_name": "Python 3", 110 | "name": "python3" 111 | }, 112 | "language_info": { 113 | "name": "python" 114 | } 115 | }, 116 | "nbformat": 4, 117 | "nbformat_minor": 0 118 | } 119 | -------------------------------------------------------------------------------- /docs/api.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/harry0703/MoneyPrinterTurbo/6cb5f2348714b08fd7cf04a416c13faeab1d356b/docs/api.jpg -------------------------------------------------------------------------------- /docs/picwish.com.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/harry0703/MoneyPrinterTurbo/6cb5f2348714b08fd7cf04a416c13faeab1d356b/docs/picwish.com.jpg -------------------------------------------------------------------------------- /docs/picwish.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/harry0703/MoneyPrinterTurbo/6cb5f2348714b08fd7cf04a416c13faeab1d356b/docs/picwish.jpg -------------------------------------------------------------------------------- /docs/reccloud.cn.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/harry0703/MoneyPrinterTurbo/6cb5f2348714b08fd7cf04a416c13faeab1d356b/docs/reccloud.cn.jpg -------------------------------------------------------------------------------- /docs/reccloud.com.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/harry0703/MoneyPrinterTurbo/6cb5f2348714b08fd7cf04a416c13faeab1d356b/docs/reccloud.com.jpg -------------------------------------------------------------------------------- /docs/voice-list.txt: -------------------------------------------------------------------------------- 1 | Name: af-ZA-AdriNeural 2 | Gender: Female 3 | 4 | Name: af-ZA-WillemNeural 5 | Gender: Male 6 | 7 | Name: am-ET-AmehaNeural 8 | Gender: Male 9 | 10 | Name: am-ET-MekdesNeural 11 | Gender: Female 12 | 13 | Name: ar-AE-FatimaNeural 14 | Gender: Female 15 | 16 | Name: ar-AE-HamdanNeural 17 | Gender: Male 18 | 19 | Name: ar-BH-AliNeural 20 | Gender: Male 21 | 22 | Name: ar-BH-LailaNeural 23 | Gender: Female 24 | 25 | Name: ar-DZ-AminaNeural 26 | Gender: Female 27 | 28 | Name: ar-DZ-IsmaelNeural 29 | Gender: Male 30 | 31 | Name: ar-EG-SalmaNeural 32 | Gender: Female 33 | 34 | Name: ar-EG-ShakirNeural 35 | Gender: Male 36 | 37 | Name: ar-IQ-BasselNeural 38 | Gender: Male 39 | 40 | Name: ar-IQ-RanaNeural 41 | Gender: Female 42 | 43 | Name: ar-JO-SanaNeural 44 | Gender: Female 45 | 46 | Name: ar-JO-TaimNeural 47 | Gender: Male 48 | 49 | Name: ar-KW-FahedNeural 50 | Gender: Male 51 | 52 | Name: ar-KW-NouraNeural 53 | Gender: Female 54 | 55 | Name: ar-LB-LaylaNeural 56 | Gender: Female 57 | 58 | Name: ar-LB-RamiNeural 59 | Gender: Male 60 | 61 | Name: ar-LY-ImanNeural 62 | Gender: Female 63 | 64 | Name: ar-LY-OmarNeural 65 | Gender: Male 66 | 67 | Name: ar-MA-JamalNeural 68 | Gender: Male 69 | 70 | Name: ar-MA-MounaNeural 71 | Gender: Female 72 | 73 | Name: ar-OM-AbdullahNeural 74 | Gender: Male 75 | 76 | Name: ar-OM-AyshaNeural 77 | Gender: Female 78 | 79 | Name: ar-QA-AmalNeural 80 | Gender: Female 81 | 82 | Name: ar-QA-MoazNeural 83 | Gender: Male 84 | 85 | Name: ar-SA-HamedNeural 86 | Gender: Male 87 | 88 | Name: ar-SA-ZariyahNeural 89 | Gender: Female 90 | 91 | Name: ar-SY-AmanyNeural 92 | Gender: Female 93 | 94 | Name: ar-SY-LaithNeural 95 | Gender: Male 96 | 97 | Name: ar-TN-HediNeural 98 | Gender: Male 99 | 100 | Name: ar-TN-ReemNeural 101 | Gender: Female 102 | 103 | Name: ar-YE-MaryamNeural 104 | Gender: Female 105 | 106 | Name: ar-YE-SalehNeural 107 | Gender: Male 108 | 109 | Name: az-AZ-BabekNeural 110 | Gender: Male 111 | 112 | Name: az-AZ-BanuNeural 113 | Gender: Female 114 | 115 | Name: bg-BG-BorislavNeural 116 | Gender: Male 117 | 118 | Name: bg-BG-KalinaNeural 119 | Gender: Female 120 | 121 | Name: bn-BD-NabanitaNeural 122 | Gender: Female 123 | 124 | Name: bn-BD-PradeepNeural 125 | Gender: Male 126 | 127 | Name: bn-IN-BashkarNeural 128 | Gender: Male 129 | 130 | Name: bn-IN-TanishaaNeural 131 | Gender: Female 132 | 133 | Name: bs-BA-GoranNeural 134 | Gender: Male 135 | 136 | Name: bs-BA-VesnaNeural 137 | Gender: Female 138 | 139 | Name: ca-ES-EnricNeural 140 | Gender: Male 141 | 142 | Name: ca-ES-JoanaNeural 143 | Gender: Female 144 | 145 | Name: cs-CZ-AntoninNeural 146 | Gender: Male 147 | 148 | Name: cs-CZ-VlastaNeural 149 | Gender: Female 150 | 151 | Name: cy-GB-AledNeural 152 | Gender: Male 153 | 154 | Name: cy-GB-NiaNeural 155 | Gender: Female 156 | 157 | Name: da-DK-ChristelNeural 158 | Gender: Female 159 | 160 | Name: da-DK-JeppeNeural 161 | Gender: Male 162 | 163 | Name: de-AT-IngridNeural 164 | Gender: Female 165 | 166 | Name: de-AT-JonasNeural 167 | Gender: Male 168 | 169 | Name: de-CH-JanNeural 170 | Gender: Male 171 | 172 | Name: de-CH-LeniNeural 173 | Gender: Female 174 | 175 | Name: de-DE-AmalaNeural 176 | Gender: Female 177 | 178 | Name: de-DE-ConradNeural 179 | Gender: Male 180 | 181 | Name: de-DE-FlorianMultilingualNeural 182 | Gender: Male 183 | 184 | Name: de-DE-KatjaNeural 185 | Gender: Female 186 | 187 | Name: de-DE-KillianNeural 188 | Gender: Male 189 | 190 | Name: de-DE-SeraphinaMultilingualNeural 191 | Gender: Female 192 | 193 | Name: el-GR-AthinaNeural 194 | Gender: Female 195 | 196 | Name: el-GR-NestorasNeural 197 | Gender: Male 198 | 199 | Name: en-AU-NatashaNeural 200 | Gender: Female 201 | 202 | Name: en-AU-WilliamNeural 203 | Gender: Male 204 | 205 | Name: en-CA-ClaraNeural 206 | Gender: Female 207 | 208 | Name: en-CA-LiamNeural 209 | Gender: Male 210 | 211 | Name: en-GB-LibbyNeural 212 | Gender: Female 213 | 214 | Name: en-GB-MaisieNeural 215 | Gender: Female 216 | 217 | Name: en-GB-RyanNeural 218 | Gender: Male 219 | 220 | Name: en-GB-SoniaNeural 221 | Gender: Female 222 | 223 | Name: en-GB-ThomasNeural 224 | Gender: Male 225 | 226 | Name: en-HK-SamNeural 227 | Gender: Male 228 | 229 | Name: en-HK-YanNeural 230 | Gender: Female 231 | 232 | Name: en-IE-ConnorNeural 233 | Gender: Male 234 | 235 | Name: en-IE-EmilyNeural 236 | Gender: Female 237 | 238 | Name: en-IN-NeerjaExpressiveNeural 239 | Gender: Female 240 | 241 | Name: en-IN-NeerjaNeural 242 | Gender: Female 243 | 244 | Name: en-IN-PrabhatNeural 245 | Gender: Male 246 | 247 | Name: en-KE-AsiliaNeural 248 | Gender: Female 249 | 250 | Name: en-KE-ChilembaNeural 251 | Gender: Male 252 | 253 | Name: en-NG-AbeoNeural 254 | Gender: Male 255 | 256 | Name: en-NG-EzinneNeural 257 | Gender: Female 258 | 259 | Name: en-NZ-MitchellNeural 260 | Gender: Male 261 | 262 | Name: en-NZ-MollyNeural 263 | Gender: Female 264 | 265 | Name: en-PH-JamesNeural 266 | Gender: Male 267 | 268 | Name: en-PH-RosaNeural 269 | Gender: Female 270 | 271 | Name: en-SG-LunaNeural 272 | Gender: Female 273 | 274 | Name: en-SG-WayneNeural 275 | Gender: Male 276 | 277 | Name: en-TZ-ElimuNeural 278 | Gender: Male 279 | 280 | Name: en-TZ-ImaniNeural 281 | Gender: Female 282 | 283 | Name: en-US-AnaNeural 284 | Gender: Female 285 | 286 | Name: en-US-AndrewNeural 287 | Gender: Male 288 | 289 | Name: en-US-AriaNeural 290 | Gender: Female 291 | 292 | Name: en-US-AvaNeural 293 | Gender: Female 294 | 295 | Name: en-US-BrianNeural 296 | Gender: Male 297 | 298 | Name: en-US-ChristopherNeural 299 | Gender: Male 300 | 301 | Name: en-US-EmmaNeural 302 | Gender: Female 303 | 304 | Name: en-US-EricNeural 305 | Gender: Male 306 | 307 | Name: en-US-GuyNeural 308 | Gender: Male 309 | 310 | Name: en-US-JennyNeural 311 | Gender: Female 312 | 313 | Name: en-US-MichelleNeural 314 | Gender: Female 315 | 316 | Name: en-US-RogerNeural 317 | Gender: Male 318 | 319 | Name: en-US-SteffanNeural 320 | Gender: Male 321 | 322 | Name: en-ZA-LeahNeural 323 | Gender: Female 324 | 325 | Name: en-ZA-LukeNeural 326 | Gender: Male 327 | 328 | Name: es-AR-ElenaNeural 329 | Gender: Female 330 | 331 | Name: es-AR-TomasNeural 332 | Gender: Male 333 | 334 | Name: es-BO-MarceloNeural 335 | Gender: Male 336 | 337 | Name: es-BO-SofiaNeural 338 | Gender: Female 339 | 340 | Name: es-CL-CatalinaNeural 341 | Gender: Female 342 | 343 | Name: es-CL-LorenzoNeural 344 | Gender: Male 345 | 346 | Name: es-CO-GonzaloNeural 347 | Gender: Male 348 | 349 | Name: es-CO-SalomeNeural 350 | Gender: Female 351 | 352 | Name: es-CR-JuanNeural 353 | Gender: Male 354 | 355 | Name: es-CR-MariaNeural 356 | Gender: Female 357 | 358 | Name: es-CU-BelkysNeural 359 | Gender: Female 360 | 361 | Name: es-CU-ManuelNeural 362 | Gender: Male 363 | 364 | Name: es-DO-EmilioNeural 365 | Gender: Male 366 | 367 | Name: es-DO-RamonaNeural 368 | Gender: Female 369 | 370 | Name: es-EC-AndreaNeural 371 | Gender: Female 372 | 373 | Name: es-EC-LuisNeural 374 | Gender: Male 375 | 376 | Name: es-ES-AlvaroNeural 377 | Gender: Male 378 | 379 | Name: es-ES-ElviraNeural 380 | Gender: Female 381 | 382 | Name: es-ES-XimenaNeural 383 | Gender: Female 384 | 385 | Name: es-GQ-JavierNeural 386 | Gender: Male 387 | 388 | Name: es-GQ-TeresaNeural 389 | Gender: Female 390 | 391 | Name: es-GT-AndresNeural 392 | Gender: Male 393 | 394 | Name: es-GT-MartaNeural 395 | Gender: Female 396 | 397 | Name: es-HN-CarlosNeural 398 | Gender: Male 399 | 400 | Name: es-HN-KarlaNeural 401 | Gender: Female 402 | 403 | Name: es-MX-DaliaNeural 404 | Gender: Female 405 | 406 | Name: es-MX-JorgeNeural 407 | Gender: Male 408 | 409 | Name: es-NI-FedericoNeural 410 | Gender: Male 411 | 412 | Name: es-NI-YolandaNeural 413 | Gender: Female 414 | 415 | Name: es-PA-MargaritaNeural 416 | Gender: Female 417 | 418 | Name: es-PA-RobertoNeural 419 | Gender: Male 420 | 421 | Name: es-PE-AlexNeural 422 | Gender: Male 423 | 424 | Name: es-PE-CamilaNeural 425 | Gender: Female 426 | 427 | Name: es-PR-KarinaNeural 428 | Gender: Female 429 | 430 | Name: es-PR-VictorNeural 431 | Gender: Male 432 | 433 | Name: es-PY-MarioNeural 434 | Gender: Male 435 | 436 | Name: es-PY-TaniaNeural 437 | Gender: Female 438 | 439 | Name: es-SV-LorenaNeural 440 | Gender: Female 441 | 442 | Name: es-SV-RodrigoNeural 443 | Gender: Male 444 | 445 | Name: es-US-AlonsoNeural 446 | Gender: Male 447 | 448 | Name: es-US-PalomaNeural 449 | Gender: Female 450 | 451 | Name: es-UY-MateoNeural 452 | Gender: Male 453 | 454 | Name: es-UY-ValentinaNeural 455 | Gender: Female 456 | 457 | Name: es-VE-PaolaNeural 458 | Gender: Female 459 | 460 | Name: es-VE-SebastianNeural 461 | Gender: Male 462 | 463 | Name: et-EE-AnuNeural 464 | Gender: Female 465 | 466 | Name: et-EE-KertNeural 467 | Gender: Male 468 | 469 | Name: fa-IR-DilaraNeural 470 | Gender: Female 471 | 472 | Name: fa-IR-FaridNeural 473 | Gender: Male 474 | 475 | Name: fi-FI-HarriNeural 476 | Gender: Male 477 | 478 | Name: fi-FI-NooraNeural 479 | Gender: Female 480 | 481 | Name: fil-PH-AngeloNeural 482 | Gender: Male 483 | 484 | Name: fil-PH-BlessicaNeural 485 | Gender: Female 486 | 487 | Name: fr-BE-CharlineNeural 488 | Gender: Female 489 | 490 | Name: fr-BE-GerardNeural 491 | Gender: Male 492 | 493 | Name: fr-CA-AntoineNeural 494 | Gender: Male 495 | 496 | Name: fr-CA-JeanNeural 497 | Gender: Male 498 | 499 | Name: fr-CA-SylvieNeural 500 | Gender: Female 501 | 502 | Name: fr-CA-ThierryNeural 503 | Gender: Male 504 | 505 | Name: fr-CH-ArianeNeural 506 | Gender: Female 507 | 508 | Name: fr-CH-FabriceNeural 509 | Gender: Male 510 | 511 | Name: fr-FR-DeniseNeural 512 | Gender: Female 513 | 514 | Name: fr-FR-EloiseNeural 515 | Gender: Female 516 | 517 | Name: fr-FR-HenriNeural 518 | Gender: Male 519 | 520 | Name: fr-FR-RemyMultilingualNeural 521 | Gender: Male 522 | 523 | Name: fr-FR-VivienneMultilingualNeural 524 | Gender: Female 525 | 526 | Name: ga-IE-ColmNeural 527 | Gender: Male 528 | 529 | Name: ga-IE-OrlaNeural 530 | Gender: Female 531 | 532 | Name: gl-ES-RoiNeural 533 | Gender: Male 534 | 535 | Name: gl-ES-SabelaNeural 536 | Gender: Female 537 | 538 | Name: gu-IN-DhwaniNeural 539 | Gender: Female 540 | 541 | Name: gu-IN-NiranjanNeural 542 | Gender: Male 543 | 544 | Name: he-IL-AvriNeural 545 | Gender: Male 546 | 547 | Name: he-IL-HilaNeural 548 | Gender: Female 549 | 550 | Name: hi-IN-MadhurNeural 551 | Gender: Male 552 | 553 | Name: hi-IN-SwaraNeural 554 | Gender: Female 555 | 556 | Name: hr-HR-GabrijelaNeural 557 | Gender: Female 558 | 559 | Name: hr-HR-SreckoNeural 560 | Gender: Male 561 | 562 | Name: hu-HU-NoemiNeural 563 | Gender: Female 564 | 565 | Name: hu-HU-TamasNeural 566 | Gender: Male 567 | 568 | Name: id-ID-ArdiNeural 569 | Gender: Male 570 | 571 | Name: id-ID-GadisNeural 572 | Gender: Female 573 | 574 | Name: is-IS-GudrunNeural 575 | Gender: Female 576 | 577 | Name: is-IS-GunnarNeural 578 | Gender: Male 579 | 580 | Name: it-IT-DiegoNeural 581 | Gender: Male 582 | 583 | Name: it-IT-ElsaNeural 584 | Gender: Female 585 | 586 | Name: it-IT-GiuseppeNeural 587 | Gender: Male 588 | 589 | Name: it-IT-IsabellaNeural 590 | Gender: Female 591 | 592 | Name: ja-JP-KeitaNeural 593 | Gender: Male 594 | 595 | Name: ja-JP-NanamiNeural 596 | Gender: Female 597 | 598 | Name: jv-ID-DimasNeural 599 | Gender: Male 600 | 601 | Name: jv-ID-SitiNeural 602 | Gender: Female 603 | 604 | Name: ka-GE-EkaNeural 605 | Gender: Female 606 | 607 | Name: ka-GE-GiorgiNeural 608 | Gender: Male 609 | 610 | Name: kk-KZ-AigulNeural 611 | Gender: Female 612 | 613 | Name: kk-KZ-DauletNeural 614 | Gender: Male 615 | 616 | Name: km-KH-PisethNeural 617 | Gender: Male 618 | 619 | Name: km-KH-SreymomNeural 620 | Gender: Female 621 | 622 | Name: kn-IN-GaganNeural 623 | Gender: Male 624 | 625 | Name: kn-IN-SapnaNeural 626 | Gender: Female 627 | 628 | Name: ko-KR-HyunsuNeural 629 | Gender: Male 630 | 631 | Name: ko-KR-InJoonNeural 632 | Gender: Male 633 | 634 | Name: ko-KR-SunHiNeural 635 | Gender: Female 636 | 637 | Name: lo-LA-ChanthavongNeural 638 | Gender: Male 639 | 640 | Name: lo-LA-KeomanyNeural 641 | Gender: Female 642 | 643 | Name: lt-LT-LeonasNeural 644 | Gender: Male 645 | 646 | Name: lt-LT-OnaNeural 647 | Gender: Female 648 | 649 | Name: lv-LV-EveritaNeural 650 | Gender: Female 651 | 652 | Name: lv-LV-NilsNeural 653 | Gender: Male 654 | 655 | Name: mk-MK-AleksandarNeural 656 | Gender: Male 657 | 658 | Name: mk-MK-MarijaNeural 659 | Gender: Female 660 | 661 | Name: ml-IN-MidhunNeural 662 | Gender: Male 663 | 664 | Name: ml-IN-SobhanaNeural 665 | Gender: Female 666 | 667 | Name: mn-MN-BataaNeural 668 | Gender: Male 669 | 670 | Name: mn-MN-YesuiNeural 671 | Gender: Female 672 | 673 | Name: mr-IN-AarohiNeural 674 | Gender: Female 675 | 676 | Name: mr-IN-ManoharNeural 677 | Gender: Male 678 | 679 | Name: ms-MY-OsmanNeural 680 | Gender: Male 681 | 682 | Name: ms-MY-YasminNeural 683 | Gender: Female 684 | 685 | Name: mt-MT-GraceNeural 686 | Gender: Female 687 | 688 | Name: mt-MT-JosephNeural 689 | Gender: Male 690 | 691 | Name: my-MM-NilarNeural 692 | Gender: Female 693 | 694 | Name: my-MM-ThihaNeural 695 | Gender: Male 696 | 697 | Name: nb-NO-FinnNeural 698 | Gender: Male 699 | 700 | Name: nb-NO-PernilleNeural 701 | Gender: Female 702 | 703 | Name: ne-NP-HemkalaNeural 704 | Gender: Female 705 | 706 | Name: ne-NP-SagarNeural 707 | Gender: Male 708 | 709 | Name: nl-BE-ArnaudNeural 710 | Gender: Male 711 | 712 | Name: nl-BE-DenaNeural 713 | Gender: Female 714 | 715 | Name: nl-NL-ColetteNeural 716 | Gender: Female 717 | 718 | Name: nl-NL-FennaNeural 719 | Gender: Female 720 | 721 | Name: nl-NL-MaartenNeural 722 | Gender: Male 723 | 724 | Name: pl-PL-MarekNeural 725 | Gender: Male 726 | 727 | Name: pl-PL-ZofiaNeural 728 | Gender: Female 729 | 730 | Name: ps-AF-GulNawazNeural 731 | Gender: Male 732 | 733 | Name: ps-AF-LatifaNeural 734 | Gender: Female 735 | 736 | Name: pt-BR-AntonioNeural 737 | Gender: Male 738 | 739 | Name: pt-BR-FranciscaNeural 740 | Gender: Female 741 | 742 | Name: pt-BR-ThalitaNeural 743 | Gender: Female 744 | 745 | Name: pt-PT-DuarteNeural 746 | Gender: Male 747 | 748 | Name: pt-PT-RaquelNeural 749 | Gender: Female 750 | 751 | Name: ro-RO-AlinaNeural 752 | Gender: Female 753 | 754 | Name: ro-RO-EmilNeural 755 | Gender: Male 756 | 757 | Name: ru-RU-DmitryNeural 758 | Gender: Male 759 | 760 | Name: ru-RU-SvetlanaNeural 761 | Gender: Female 762 | 763 | Name: si-LK-SameeraNeural 764 | Gender: Male 765 | 766 | Name: si-LK-ThiliniNeural 767 | Gender: Female 768 | 769 | Name: sk-SK-LukasNeural 770 | Gender: Male 771 | 772 | Name: sk-SK-ViktoriaNeural 773 | Gender: Female 774 | 775 | Name: sl-SI-PetraNeural 776 | Gender: Female 777 | 778 | Name: sl-SI-RokNeural 779 | Gender: Male 780 | 781 | Name: so-SO-MuuseNeural 782 | Gender: Male 783 | 784 | Name: so-SO-UbaxNeural 785 | Gender: Female 786 | 787 | Name: sq-AL-AnilaNeural 788 | Gender: Female 789 | 790 | Name: sq-AL-IlirNeural 791 | Gender: Male 792 | 793 | Name: sr-RS-NicholasNeural 794 | Gender: Male 795 | 796 | Name: sr-RS-SophieNeural 797 | Gender: Female 798 | 799 | Name: su-ID-JajangNeural 800 | Gender: Male 801 | 802 | Name: su-ID-TutiNeural 803 | Gender: Female 804 | 805 | Name: sv-SE-MattiasNeural 806 | Gender: Male 807 | 808 | Name: sv-SE-SofieNeural 809 | Gender: Female 810 | 811 | Name: sw-KE-RafikiNeural 812 | Gender: Male 813 | 814 | Name: sw-KE-ZuriNeural 815 | Gender: Female 816 | 817 | Name: sw-TZ-DaudiNeural 818 | Gender: Male 819 | 820 | Name: sw-TZ-RehemaNeural 821 | Gender: Female 822 | 823 | Name: ta-IN-PallaviNeural 824 | Gender: Female 825 | 826 | Name: ta-IN-ValluvarNeural 827 | Gender: Male 828 | 829 | Name: ta-LK-KumarNeural 830 | Gender: Male 831 | 832 | Name: ta-LK-SaranyaNeural 833 | Gender: Female 834 | 835 | Name: ta-MY-KaniNeural 836 | Gender: Female 837 | 838 | Name: ta-MY-SuryaNeural 839 | Gender: Male 840 | 841 | Name: ta-SG-AnbuNeural 842 | Gender: Male 843 | 844 | Name: ta-SG-VenbaNeural 845 | Gender: Female 846 | 847 | Name: te-IN-MohanNeural 848 | Gender: Male 849 | 850 | Name: te-IN-ShrutiNeural 851 | Gender: Female 852 | 853 | Name: th-TH-NiwatNeural 854 | Gender: Male 855 | 856 | Name: th-TH-PremwadeeNeural 857 | Gender: Female 858 | 859 | Name: tr-TR-AhmetNeural 860 | Gender: Male 861 | 862 | Name: tr-TR-EmelNeural 863 | Gender: Female 864 | 865 | Name: uk-UA-OstapNeural 866 | Gender: Male 867 | 868 | Name: uk-UA-PolinaNeural 869 | Gender: Female 870 | 871 | Name: ur-IN-GulNeural 872 | Gender: Female 873 | 874 | Name: ur-IN-SalmanNeural 875 | Gender: Male 876 | 877 | Name: ur-PK-AsadNeural 878 | Gender: Male 879 | 880 | Name: ur-PK-UzmaNeural 881 | Gender: Female 882 | 883 | Name: uz-UZ-MadinaNeural 884 | Gender: Female 885 | 886 | Name: uz-UZ-SardorNeural 887 | Gender: Male 888 | 889 | Name: vi-VN-HoaiMyNeural 890 | Gender: Female 891 | 892 | Name: vi-VN-NamMinhNeural 893 | Gender: Male 894 | 895 | Name: zh-CN-XiaoxiaoNeural 896 | Gender: Female 897 | 898 | Name: zh-CN-XiaoyiNeural 899 | Gender: Female 900 | 901 | Name: zh-CN-YunjianNeural 902 | Gender: Male 903 | 904 | Name: zh-CN-YunxiNeural 905 | Gender: Male 906 | 907 | Name: zh-CN-YunxiaNeural 908 | Gender: Male 909 | 910 | Name: zh-CN-YunyangNeural 911 | Gender: Male 912 | 913 | Name: zh-CN-liaoning-XiaobeiNeural 914 | Gender: Female 915 | 916 | Name: zh-CN-shaanxi-XiaoniNeural 917 | Gender: Female 918 | 919 | Name: zh-HK-HiuGaaiNeural 920 | Gender: Female 921 | 922 | Name: zh-HK-HiuMaanNeural 923 | Gender: Female 924 | 925 | Name: zh-HK-WanLungNeural 926 | Gender: Male 927 | 928 | Name: zh-TW-HsiaoChenNeural 929 | Gender: Female 930 | 931 | Name: zh-TW-HsiaoYuNeural 932 | Gender: Female 933 | 934 | Name: zh-TW-YunJheNeural 935 | Gender: Male 936 | 937 | Name: zu-ZA-ThandoNeural 938 | Gender: Female 939 | 940 | Name: zu-ZA-ThembaNeural 941 | Gender: Male 942 | -------------------------------------------------------------------------------- /docs/webui-en.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/harry0703/MoneyPrinterTurbo/6cb5f2348714b08fd7cf04a416c13faeab1d356b/docs/webui-en.jpg -------------------------------------------------------------------------------- /docs/webui.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/harry0703/MoneyPrinterTurbo/6cb5f2348714b08fd7cf04a416c13faeab1d356b/docs/webui.jpg -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 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 | ) 17 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | moviepy==2.1.2 2 | streamlit==1.45.0 3 | edge_tts==6.1.19 4 | fastapi==0.115.6 5 | uvicorn==0.32.1 6 | openai==1.56.1 7 | faster-whisper==1.1.0 8 | loguru==0.7.3 9 | google.generativeai==0.8.3 10 | dashscope==1.20.14 11 | g4f==0.5.2.2 12 | azure-cognitiveservices-speech==1.41.1 13 | redis==5.2.0 14 | python-multipart==0.0.19 15 | pyyaml 16 | requests>=2.31.0 17 | -------------------------------------------------------------------------------- /resource/fonts/Charm-Bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/harry0703/MoneyPrinterTurbo/6cb5f2348714b08fd7cf04a416c13faeab1d356b/resource/fonts/Charm-Bold.ttf -------------------------------------------------------------------------------- /resource/fonts/Charm-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/harry0703/MoneyPrinterTurbo/6cb5f2348714b08fd7cf04a416c13faeab1d356b/resource/fonts/Charm-Regular.ttf -------------------------------------------------------------------------------- /resource/fonts/MicrosoftYaHeiBold.ttc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/harry0703/MoneyPrinterTurbo/6cb5f2348714b08fd7cf04a416c13faeab1d356b/resource/fonts/MicrosoftYaHeiBold.ttc -------------------------------------------------------------------------------- /resource/fonts/MicrosoftYaHeiNormal.ttc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/harry0703/MoneyPrinterTurbo/6cb5f2348714b08fd7cf04a416c13faeab1d356b/resource/fonts/MicrosoftYaHeiNormal.ttc -------------------------------------------------------------------------------- /resource/fonts/STHeitiLight.ttc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/harry0703/MoneyPrinterTurbo/6cb5f2348714b08fd7cf04a416c13faeab1d356b/resource/fonts/STHeitiLight.ttc -------------------------------------------------------------------------------- /resource/fonts/STHeitiMedium.ttc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/harry0703/MoneyPrinterTurbo/6cb5f2348714b08fd7cf04a416c13faeab1d356b/resource/fonts/STHeitiMedium.ttc -------------------------------------------------------------------------------- /resource/fonts/UTM Kabel KT.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/harry0703/MoneyPrinterTurbo/6cb5f2348714b08fd7cf04a416c13faeab1d356b/resource/fonts/UTM Kabel KT.ttf -------------------------------------------------------------------------------- /resource/public/index.html: -------------------------------------------------------------------------------- 1 | <!DOCTYPE html> 2 | <html lang="en"> 3 | <head> 4 | <meta charset="UTF-8"> 5 | <title>MoneyPrinterTurbo</title> 6 | </head> 7 | <body> 8 | <h1>MoneyPrinterTurbo</h1> 9 | <a href="https://github.com/harry0703/MoneyPrinterTurbo">https://github.com/harry0703/MoneyPrinterTurbo</a> 10 | <p> 11 | 只需提供一个视频 主题 或 关键词 ,就可以全自动生成视频文案、视频素材、视频字幕、视频背景音乐,然后合成一个高清的短视频。 12 | </p> 13 | 14 | <p> 15 | Simply provide a topic or keyword for a video, and it will automatically generate the video copy, video materials, 16 | video subtitles, and video background music before synthesizing a high-definition short video. 17 | </p> 18 | </body> 19 | </html> -------------------------------------------------------------------------------- /resource/songs/output000.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/harry0703/MoneyPrinterTurbo/6cb5f2348714b08fd7cf04a416c13faeab1d356b/resource/songs/output000.mp3 -------------------------------------------------------------------------------- /resource/songs/output001.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/harry0703/MoneyPrinterTurbo/6cb5f2348714b08fd7cf04a416c13faeab1d356b/resource/songs/output001.mp3 -------------------------------------------------------------------------------- /resource/songs/output002.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/harry0703/MoneyPrinterTurbo/6cb5f2348714b08fd7cf04a416c13faeab1d356b/resource/songs/output002.mp3 -------------------------------------------------------------------------------- /resource/songs/output003.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/harry0703/MoneyPrinterTurbo/6cb5f2348714b08fd7cf04a416c13faeab1d356b/resource/songs/output003.mp3 -------------------------------------------------------------------------------- /resource/songs/output004.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/harry0703/MoneyPrinterTurbo/6cb5f2348714b08fd7cf04a416c13faeab1d356b/resource/songs/output004.mp3 -------------------------------------------------------------------------------- /resource/songs/output005.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/harry0703/MoneyPrinterTurbo/6cb5f2348714b08fd7cf04a416c13faeab1d356b/resource/songs/output005.mp3 -------------------------------------------------------------------------------- /resource/songs/output006.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/harry0703/MoneyPrinterTurbo/6cb5f2348714b08fd7cf04a416c13faeab1d356b/resource/songs/output006.mp3 -------------------------------------------------------------------------------- /resource/songs/output007.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/harry0703/MoneyPrinterTurbo/6cb5f2348714b08fd7cf04a416c13faeab1d356b/resource/songs/output007.mp3 -------------------------------------------------------------------------------- /resource/songs/output008.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/harry0703/MoneyPrinterTurbo/6cb5f2348714b08fd7cf04a416c13faeab1d356b/resource/songs/output008.mp3 -------------------------------------------------------------------------------- /resource/songs/output009.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/harry0703/MoneyPrinterTurbo/6cb5f2348714b08fd7cf04a416c13faeab1d356b/resource/songs/output009.mp3 -------------------------------------------------------------------------------- /resource/songs/output010.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/harry0703/MoneyPrinterTurbo/6cb5f2348714b08fd7cf04a416c13faeab1d356b/resource/songs/output010.mp3 -------------------------------------------------------------------------------- /resource/songs/output011.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/harry0703/MoneyPrinterTurbo/6cb5f2348714b08fd7cf04a416c13faeab1d356b/resource/songs/output011.mp3 -------------------------------------------------------------------------------- /resource/songs/output012.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/harry0703/MoneyPrinterTurbo/6cb5f2348714b08fd7cf04a416c13faeab1d356b/resource/songs/output012.mp3 -------------------------------------------------------------------------------- /resource/songs/output013.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/harry0703/MoneyPrinterTurbo/6cb5f2348714b08fd7cf04a416c13faeab1d356b/resource/songs/output013.mp3 -------------------------------------------------------------------------------- /resource/songs/output014.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/harry0703/MoneyPrinterTurbo/6cb5f2348714b08fd7cf04a416c13faeab1d356b/resource/songs/output014.mp3 -------------------------------------------------------------------------------- /resource/songs/output015.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/harry0703/MoneyPrinterTurbo/6cb5f2348714b08fd7cf04a416c13faeab1d356b/resource/songs/output015.mp3 -------------------------------------------------------------------------------- /resource/songs/output016.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/harry0703/MoneyPrinterTurbo/6cb5f2348714b08fd7cf04a416c13faeab1d356b/resource/songs/output016.mp3 -------------------------------------------------------------------------------- /resource/songs/output017.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/harry0703/MoneyPrinterTurbo/6cb5f2348714b08fd7cf04a416c13faeab1d356b/resource/songs/output017.mp3 -------------------------------------------------------------------------------- /resource/songs/output018.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/harry0703/MoneyPrinterTurbo/6cb5f2348714b08fd7cf04a416c13faeab1d356b/resource/songs/output018.mp3 -------------------------------------------------------------------------------- /resource/songs/output019.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/harry0703/MoneyPrinterTurbo/6cb5f2348714b08fd7cf04a416c13faeab1d356b/resource/songs/output019.mp3 -------------------------------------------------------------------------------- /resource/songs/output020.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/harry0703/MoneyPrinterTurbo/6cb5f2348714b08fd7cf04a416c13faeab1d356b/resource/songs/output020.mp3 -------------------------------------------------------------------------------- /resource/songs/output021.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/harry0703/MoneyPrinterTurbo/6cb5f2348714b08fd7cf04a416c13faeab1d356b/resource/songs/output021.mp3 -------------------------------------------------------------------------------- /resource/songs/output022.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/harry0703/MoneyPrinterTurbo/6cb5f2348714b08fd7cf04a416c13faeab1d356b/resource/songs/output022.mp3 -------------------------------------------------------------------------------- /resource/songs/output023.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/harry0703/MoneyPrinterTurbo/6cb5f2348714b08fd7cf04a416c13faeab1d356b/resource/songs/output023.mp3 -------------------------------------------------------------------------------- /resource/songs/output024.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/harry0703/MoneyPrinterTurbo/6cb5f2348714b08fd7cf04a416c13faeab1d356b/resource/songs/output024.mp3 -------------------------------------------------------------------------------- /resource/songs/output025.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/harry0703/MoneyPrinterTurbo/6cb5f2348714b08fd7cf04a416c13faeab1d356b/resource/songs/output025.mp3 -------------------------------------------------------------------------------- /resource/songs/output027.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/harry0703/MoneyPrinterTurbo/6cb5f2348714b08fd7cf04a416c13faeab1d356b/resource/songs/output027.mp3 -------------------------------------------------------------------------------- /resource/songs/output028.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/harry0703/MoneyPrinterTurbo/6cb5f2348714b08fd7cf04a416c13faeab1d356b/resource/songs/output028.mp3 -------------------------------------------------------------------------------- /resource/songs/output029.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/harry0703/MoneyPrinterTurbo/6cb5f2348714b08fd7cf04a416c13faeab1d356b/resource/songs/output029.mp3 -------------------------------------------------------------------------------- /test/README.md: -------------------------------------------------------------------------------- 1 | # MoneyPrinterTurbo Test Directory 2 | 3 | This directory contains unit tests for the **MoneyPrinterTurbo** project. 4 | 5 | ## Directory Structure 6 | 7 | - `services/`: Tests for components in the `app/services` directory 8 | - `test_video.py`: Tests for the video service 9 | - `test_task.py`: Tests for the task service 10 | - `test_voice.py`: Tests for the voice service 11 | 12 | ## Running Tests 13 | 14 | You can run the tests using Python’s built-in `unittest` framework: 15 | 16 | ```bash 17 | # Run all tests 18 | python -m unittest discover -s test 19 | 20 | # Run a specific test file 21 | python -m unittest test/services/test_video.py 22 | 23 | # Run a specific test class 24 | python -m unittest test.services.test_video.TestVideoService 25 | 26 | # Run a specific test method 27 | python -m unittest test.services.test_video.TestVideoService.test_preprocess_video 28 | ```` 29 | 30 | ## Adding New Tests 31 | 32 | To add tests for other components, follow these guidelines: 33 | 34 | 1. Create test files prefixed with `test_` in the appropriate subdirectory 35 | 2. Use `unittest.TestCase` as the base class for your test classes 36 | 3. Name test methods with the `test_` prefix 37 | 38 | ## Test Resources 39 | 40 | Place any resource files required for testing in the `test/resources` directory. -------------------------------------------------------------------------------- /test/__init__.py: -------------------------------------------------------------------------------- 1 | # Unit test package for test 2 | -------------------------------------------------------------------------------- /test/resources/1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/harry0703/MoneyPrinterTurbo/6cb5f2348714b08fd7cf04a416c13faeab1d356b/test/resources/1.png -------------------------------------------------------------------------------- /test/resources/1.png.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/harry0703/MoneyPrinterTurbo/6cb5f2348714b08fd7cf04a416c13faeab1d356b/test/resources/1.png.mp4 -------------------------------------------------------------------------------- /test/resources/2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/harry0703/MoneyPrinterTurbo/6cb5f2348714b08fd7cf04a416c13faeab1d356b/test/resources/2.png -------------------------------------------------------------------------------- /test/resources/2.png.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/harry0703/MoneyPrinterTurbo/6cb5f2348714b08fd7cf04a416c13faeab1d356b/test/resources/2.png.mp4 -------------------------------------------------------------------------------- /test/resources/3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/harry0703/MoneyPrinterTurbo/6cb5f2348714b08fd7cf04a416c13faeab1d356b/test/resources/3.png -------------------------------------------------------------------------------- /test/resources/3.png.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/harry0703/MoneyPrinterTurbo/6cb5f2348714b08fd7cf04a416c13faeab1d356b/test/resources/3.png.mp4 -------------------------------------------------------------------------------- /test/resources/4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/harry0703/MoneyPrinterTurbo/6cb5f2348714b08fd7cf04a416c13faeab1d356b/test/resources/4.png -------------------------------------------------------------------------------- /test/resources/5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/harry0703/MoneyPrinterTurbo/6cb5f2348714b08fd7cf04a416c13faeab1d356b/test/resources/5.png -------------------------------------------------------------------------------- /test/resources/6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/harry0703/MoneyPrinterTurbo/6cb5f2348714b08fd7cf04a416c13faeab1d356b/test/resources/6.png -------------------------------------------------------------------------------- /test/resources/7.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/harry0703/MoneyPrinterTurbo/6cb5f2348714b08fd7cf04a416c13faeab1d356b/test/resources/7.png -------------------------------------------------------------------------------- /test/resources/8.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/harry0703/MoneyPrinterTurbo/6cb5f2348714b08fd7cf04a416c13faeab1d356b/test/resources/8.png -------------------------------------------------------------------------------- /test/resources/9.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/harry0703/MoneyPrinterTurbo/6cb5f2348714b08fd7cf04a416c13faeab1d356b/test/resources/9.png -------------------------------------------------------------------------------- /test/services/__init__.py: -------------------------------------------------------------------------------- 1 | # Unit test package for services -------------------------------------------------------------------------------- /test/services/test_task.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import os 3 | import sys 4 | from pathlib import Path 5 | 6 | # add project root to python path 7 | sys.path.insert(0, str(Path(__file__).parent.parent.parent)) 8 | 9 | from app.services import task as tm 10 | from app.models.schema import MaterialInfo, VideoParams 11 | 12 | resources_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), "resources") 13 | 14 | class TestTaskService(unittest.TestCase): 15 | def setUp(self): 16 | pass 17 | 18 | def tearDown(self): 19 | pass 20 | 21 | def test_task_local_materials(self): 22 | task_id = "00000000-0000-0000-0000-000000000000" 23 | video_materials=[] 24 | for i in range(1, 4): 25 | video_materials.append(MaterialInfo( 26 | provider="local", 27 | url=os.path.join(resources_dir, f"{i}.png"), 28 | duration=0 29 | )) 30 | 31 | params = VideoParams( 32 | video_subject="金钱的作用", 33 | video_script="金钱不仅是交换媒介,更是社会资源的分配工具。它能满足基本生存需求,如食物和住房,也能提供教育、医疗等提升生活品质的机会。拥有足够的金钱意味着更多选择权,比如职业自由或创业可能。但金钱的作用也有边界,它无法直接购买幸福、健康或真诚的人际关系。过度追逐财富可能导致价值观扭曲,忽视精神层面的需求。理想的状态是理性看待金钱,将其作为实现目标的工具而非终极目的。", 34 | video_terms="money importance, wealth and society, financial freedom, money and happiness, role of money", 35 | video_aspect="9:16", 36 | video_concat_mode="random", 37 | video_transition_mode="None", 38 | video_clip_duration=3, 39 | video_count=1, 40 | video_source="local", 41 | video_materials=video_materials, 42 | video_language="", 43 | voice_name="zh-CN-XiaoxiaoNeural-Female", 44 | voice_volume=1.0, 45 | voice_rate=1.0, 46 | bgm_type="random", 47 | bgm_file="", 48 | bgm_volume=0.2, 49 | subtitle_enabled=True, 50 | subtitle_position="bottom", 51 | custom_position=70.0, 52 | font_name="MicrosoftYaHeiBold.ttc", 53 | text_fore_color="#FFFFFF", 54 | text_background_color=True, 55 | font_size=60, 56 | stroke_color="#000000", 57 | stroke_width=1.5, 58 | n_threads=2, 59 | paragraph_number=1 60 | ) 61 | result = tm.start(task_id=task_id, params=params) 62 | print(result) 63 | 64 | 65 | if __name__ == "__main__": 66 | unittest.main() -------------------------------------------------------------------------------- /test/services/test_video.py: -------------------------------------------------------------------------------- 1 | 2 | import unittest 3 | import os 4 | import sys 5 | from pathlib import Path 6 | from moviepy import ( 7 | VideoFileClip, 8 | ) 9 | # add project root to python path 10 | sys.path.insert(0, str(Path(__file__).parent.parent.parent)) 11 | from app.models.schema import MaterialInfo 12 | from app.services import video as vd 13 | from app.utils import utils 14 | 15 | resources_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), "resources") 16 | 17 | class TestVideoService(unittest.TestCase): 18 | def setUp(self): 19 | self.test_img_path = os.path.join(resources_dir, "1.png") 20 | 21 | def tearDown(self): 22 | pass 23 | 24 | def test_preprocess_video(self): 25 | if not os.path.exists(self.test_img_path): 26 | self.fail(f"test image not found: {self.test_img_path}") 27 | 28 | # test preprocess_video function 29 | m = MaterialInfo() 30 | m.url = self.test_img_path 31 | m.provider = "local" 32 | print(m) 33 | 34 | materials = vd.preprocess_video([m], clip_duration=4) 35 | print(materials) 36 | 37 | # verify result 38 | self.assertIsNotNone(materials) 39 | self.assertEqual(len(materials), 1) 40 | self.assertTrue(materials[0].url.endswith(".mp4")) 41 | 42 | # moviepy get video info 43 | clip = VideoFileClip(materials[0].url) 44 | print(clip) 45 | 46 | # clean generated test video file 47 | if os.path.exists(materials[0].url): 48 | os.remove(materials[0].url) 49 | 50 | def test_wrap_text(self): 51 | """test text wrapping function""" 52 | try: 53 | font_path = os.path.join(utils.font_dir(), "STHeitiMedium.ttc") 54 | if not os.path.exists(font_path): 55 | self.fail(f"font file not found: {font_path}") 56 | 57 | # test english text wrapping 58 | test_text_en = "This is a test text for wrapping long sentences in english language" 59 | 60 | wrapped_text_en, text_height_en = vd.wrap_text( 61 | text=test_text_en, 62 | max_width=300, 63 | font=font_path, 64 | fontsize=30 65 | ) 66 | print(wrapped_text_en, text_height_en) 67 | # verify text is wrapped 68 | self.assertIn("\n", wrapped_text_en) 69 | 70 | # test chinese text wrapping 71 | test_text_zh = "这是一段用来测试中文长句换行的文本内容,应该会根据宽度限制进行换行处理" 72 | wrapped_text_zh, text_height_zh = vd.wrap_text( 73 | text=test_text_zh, 74 | max_width=300, 75 | font=font_path, 76 | fontsize=30 77 | ) 78 | print(wrapped_text_zh, text_height_zh) 79 | # verify chinese text is wrapped 80 | self.assertIn("\n", wrapped_text_zh) 81 | except Exception as e: 82 | self.fail(f"test wrap_text failed: {str(e)}") 83 | 84 | if __name__ == "__main__": 85 | unittest.main() -------------------------------------------------------------------------------- /test/services/test_voice.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import unittest 3 | import os 4 | import sys 5 | from pathlib import Path 6 | 7 | # add project root to python path 8 | sys.path.insert(0, str(Path(__file__).parent.parent.parent)) 9 | 10 | from app.utils import utils 11 | from app.services import voice as vs 12 | 13 | temp_dir = utils.storage_dir("temp") 14 | 15 | text_en = """ 16 | What is the meaning of life? 17 | This question has puzzled philosophers, scientists, and thinkers of all kinds for centuries. 18 | Throughout history, various cultures and individuals have come up with their interpretations and beliefs around the purpose of life. 19 | 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. 20 | 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. 21 | It's an existential inquiry that encourages us to reflect on our values, desires, and the essence of our existence. 22 | """ 23 | 24 | text_zh = """ 25 | 预计未来3天深圳冷空气活动频繁,未来两天持续阴天有小雨,出门带好雨具; 26 | 10-11日持续阴天有小雨,日温差小,气温在13-17℃之间,体感阴凉; 27 | 12日天气短暂好转,早晚清凉; 28 | """ 29 | 30 | voice_rate=1.0 31 | voice_volume=1.0 32 | 33 | class TestVoiceService(unittest.TestCase): 34 | def setUp(self): 35 | self.loop = asyncio.new_event_loop() 36 | asyncio.set_event_loop(self.loop) 37 | 38 | def tearDown(self): 39 | self.loop.close() 40 | 41 | def test_siliconflow(self): 42 | voice_name = "siliconflow:FunAudioLLM/CosyVoice2-0.5B:alex-Male" 43 | voice_name = vs.parse_voice_name(voice_name) 44 | 45 | async def _do(): 46 | parts = voice_name.split(":") 47 | if len(parts) >= 3: 48 | model = parts[1] 49 | # 移除性别后缀,例如 "alex-Male" -> "alex" 50 | voice_with_gender = parts[2] 51 | voice = voice_with_gender.split("-")[0] 52 | # 构建完整的voice参数,格式为 "model:voice" 53 | full_voice = f"{model}:{voice}" 54 | voice_file = f"{temp_dir}/tts-siliconflow-{voice}.mp3" 55 | subtitle_file = f"{temp_dir}/tts-siliconflow-{voice}.srt" 56 | sub_maker = vs.siliconflow_tts( 57 | text=text_zh, model=model, voice=full_voice, voice_file=voice_file, voice_rate=voice_rate, voice_volume=voice_volume 58 | ) 59 | if not sub_maker: 60 | self.fail("siliconflow tts failed") 61 | vs.create_subtitle(sub_maker=sub_maker, text=text_zh, subtitle_file=subtitle_file) 62 | audio_duration = vs.get_audio_duration(sub_maker) 63 | print(f"voice: {voice_name}, audio duration: {audio_duration}s") 64 | else: 65 | self.fail("siliconflow invalid voice name") 66 | 67 | self.loop.run_until_complete(_do()) 68 | 69 | def test_azure_tts_v1(self): 70 | voice_name = "zh-CN-XiaoyiNeural-Female" 71 | voice_name = vs.parse_voice_name(voice_name) 72 | print(voice_name) 73 | 74 | voice_file = f"{temp_dir}/tts-azure-v1-{voice_name}.mp3" 75 | subtitle_file = f"{temp_dir}/tts-azure-v1-{voice_name}.srt" 76 | sub_maker = vs.azure_tts_v1( 77 | text=text_zh, voice_name=voice_name, voice_file=voice_file, voice_rate=voice_rate 78 | ) 79 | if not sub_maker: 80 | self.fail("azure tts v1 failed") 81 | vs.create_subtitle(sub_maker=sub_maker, text=text_zh, subtitle_file=subtitle_file) 82 | audio_duration = vs.get_audio_duration(sub_maker) 83 | print(f"voice: {voice_name}, audio duration: {audio_duration}s") 84 | 85 | def test_azure_tts_v2(self): 86 | voice_name = "zh-CN-XiaoxiaoMultilingualNeural-V2-Female" 87 | voice_name = vs.parse_voice_name(voice_name) 88 | print(voice_name) 89 | 90 | async def _do(): 91 | voice_file = f"{temp_dir}/tts-azure-v2-{voice_name}.mp3" 92 | subtitle_file = f"{temp_dir}/tts-azure-v2-{voice_name}.srt" 93 | sub_maker = vs.azure_tts_v2( 94 | text=text_zh, voice_name=voice_name, voice_file=voice_file 95 | ) 96 | if not sub_maker: 97 | self.fail("azure tts v2 failed") 98 | vs.create_subtitle(sub_maker=sub_maker, text=text_zh, subtitle_file=subtitle_file) 99 | audio_duration = vs.get_audio_duration(sub_maker) 100 | print(f"voice: {voice_name}, audio duration: {audio_duration}s") 101 | 102 | self.loop.run_until_complete(_do()) 103 | 104 | if __name__ == "__main__": 105 | # python -m unittest test.services.test_voice.TestVoiceService.test_azure_tts_v1 106 | # python -m unittest test.services.test_voice.TestVoiceService.test_azure_tts_v2 107 | unittest.main() -------------------------------------------------------------------------------- /webui.bat: -------------------------------------------------------------------------------- 1 | @echo off 2 | set CURRENT_DIR=%CD% 3 | echo ***** Current directory: %CURRENT_DIR% ***** 4 | set PYTHONPATH=%CURRENT_DIR% 5 | 6 | rem set HF_ENDPOINT=https://hf-mirror.com 7 | streamlit run .\webui\Main.py --browser.gatherUsageStats=False --server.enableCORS=True -------------------------------------------------------------------------------- /webui.sh: -------------------------------------------------------------------------------- 1 | # If you could not download the model from the official site, you can use the mirror site. 2 | # Just remove the comment of the following line . 3 | # 如果你无法从官方网站下载模型,你可以使用镜像网站。 4 | # 只需要移除下面一行的注释即可。 5 | 6 | # export HF_ENDPOINT=https://hf-mirror.com 7 | 8 | streamlit run ./webui/Main.py --browser.serverAddress="0.0.0.0" --server.enableCORS=True --browser.gatherUsageStats=False -------------------------------------------------------------------------------- /webui/.streamlit/config.toml: -------------------------------------------------------------------------------- 1 | [browser] 2 | gatherUsageStats = false -------------------------------------------------------------------------------- /webui/i18n/de.json: -------------------------------------------------------------------------------- 1 | { 2 | "Language": "Deutsch", 3 | "Translation": { 4 | "Login Required": "Anmeldung erforderlich", 5 | "Please login to access settings": "Bitte melden Sie sich an, um auf die Einstellungen zuzugreifen", 6 | "Username": "Benutzername", 7 | "Password": "Passwort", 8 | "Login": "Anmelden", 9 | "Login Error": "Anmeldefehler", 10 | "Incorrect username or password": "Falscher Benutzername oder Passwort", 11 | "Please enter your username and password": "Bitte geben Sie Ihren Benutzernamen und Ihr Passwort ein", 12 | "Video Script Settings": "**Drehbuch / Topic des Videos**", 13 | "Video Subject": "Worum soll es in dem Video gehen? (Geben Sie ein Keyword an, :red[Dank KI wird automatisch ein Drehbuch generieren])", 14 | "Script Language": "Welche Sprache soll zum Generieren von Drehbüchern verwendet werden? :red[KI generiert anhand dieses Begriffs das Drehbuch]", 15 | "Generate Video Script and Keywords": "Klicken Sie hier, um mithilfe von KI ein [Video Drehbuch] und [Video Keywords] basierend auf dem **Keyword** zu generieren.", 16 | "Auto Detect": "Automatisch erkennen", 17 | "Video Script": "Drehbuch (Storybook) (:blue[① Optional, KI generiert ② Die richtige Zeichensetzung hilft bei der Erstellung von Untertiteln])", 18 | "Generate Video Keywords": "Klicken Sie, um KI zum Generieren zu verwenden [Video Keywords] basierend auf dem **Drehbuch**", 19 | "Please Enter the Video Subject": "Bitte geben Sie zuerst das Drehbuch an", 20 | "Generating Video Script and Keywords": "KI generiert ein Drehbuch und Schlüsselwörter...", 21 | "Generating Video Keywords": "KI generiert Video-Schlüsselwörter...", 22 | "Video Keywords": "Video Schlüsselwörter (:blue[① Optional, KI generiert ② Verwende **, (Kommas)** zur Trennung der Wörter, in englischer Sprache])", 23 | "Video Settings": "**Video Einstellungen**", 24 | "Video Concat Mode": "Videoverkettungsmodus", 25 | "Random": "Zufällige Verkettung (empfohlen)", 26 | "Sequential": "Sequentielle Verkettung", 27 | "Video Transition Mode": "Video Übergangsmodus", 28 | "None": "Kein Übergang", 29 | "Shuffle": "Zufällige Übergänge", 30 | "FadeIn": "FadeIn", 31 | "FadeOut": "FadeOut", 32 | "SlideIn": "SlideIn", 33 | "SlideOut": "SlideOut", 34 | "Video Ratio": "Video-Seitenverhältnis", 35 | "Portrait": "Portrait 9:16", 36 | "Landscape": "Landschaft 16:9", 37 | "Clip Duration": "Maximale Dauer einzelner Videoclips in sekunden", 38 | "Number of Videos Generated Simultaneously": "Anzahl der parallel generierten Videos", 39 | "Audio Settings": "**Audio Einstellungen**", 40 | "Speech Synthesis": "Sprachausgabe", 41 | "Speech Region": "Region(:red[Erforderlich,[Region abrufen](https://portal.azure.com/#view/Microsoft_Azure_ProjectOxford/CognitiveServicesHub/~/SpeechServices)])", 42 | "Speech Key": "API-Schlüssel(:red[Erforderlich,[API-Schlüssel abrufen](https://portal.azure.com/#view/Microsoft_Azure_ProjectOxford/CognitiveServicesHub/~/SpeechServices)])", 43 | "Speech Volume": "Lautstärke der Sprachausgabe", 44 | "Speech Rate": "Lesegeschwindigkeit (1,0 bedeutet 1x)", 45 | "Male": "Männlich", 46 | "Female": "Weiblich", 47 | "Background Music": "Hintergrundmusik", 48 | "No Background Music": "Ohne Hintergrundmusik", 49 | "Random Background Music": "Zufällig erzeugte Hintergrundmusik", 50 | "Custom Background Music": "Benutzerdefinierte Hintergrundmusik", 51 | "Custom Background Music File": "Bitte gib den Pfad zur Musikdatei an:", 52 | "Background Music Volume": "Lautstärke: (0.2 entspricht 20%, sollte nicht zu laut sein)", 53 | "Subtitle Settings": "**Untertitel-Einstellungen**", 54 | "Enable Subtitles": "Untertitel aktivieren (Wenn diese Option deaktiviert ist, werden die Einstellungen nicht genutzt)", 55 | "Font": "Schriftart des Untertitels", 56 | "Position": "Ausrichtung des Untertitels", 57 | "Top": "Oben", 58 | "Center": "Mittig", 59 | "Bottom": "Unten (empfohlen)", 60 | "Custom": "Benutzerdefinierte Position (70, was 70% von oben bedeutet)", 61 | "Font Size": "Schriftgröße für Untertitel", 62 | "Font Color": "Schriftfarbe", 63 | "Stroke Color": "Kontur", 64 | "Stroke Width": "Breite der Untertitelkontur", 65 | "Generate Video": "Generiere Videos durch KI", 66 | "Video Script and Subject Cannot Both Be Empty": "Das Video-Thema und Drehbuch dürfen nicht beide leer sein", 67 | "Generating Video": "Video wird erstellt, bitte warten...", 68 | "Start Generating Video": "Beginne mit der Generierung", 69 | "Video Generation Completed": "Video erfolgreich generiert", 70 | "Video Generation Failed": "Video Generierung fehlgeschlagen", 71 | "You can download the generated video from the following links": "Sie können das generierte Video über die folgenden Links herunterladen", 72 | "Basic Settings": "**Grundeinstellungen** (:blue[Klicken zum Erweitern])", 73 | "Language": "Sprache", 74 | "Pexels API Key": "Pexels API-Schlüssel ([API-Schlüssel abrufen](https://www.pexels.com/api/))", 75 | "Pixabay API Key": "Pixabay API-Schlüssel ([API-Schlüssel abrufen](https://pixabay.com/api/docs/#api_search_videos))", 76 | "LLM Provider": "KI-Modellanbieter", 77 | "API Key": "API-Schlüssel (:red[Erforderlich])", 78 | "Base Url": "Basis-URL", 79 | "Account ID": "Konto-ID (Aus dem Cloudflare-Dashboard)", 80 | "Model Name": "Modellname", 81 | "Please Enter the LLM API Key": "Bitte geben Sie den **KI-Modell API-Schlüssel** ein", 82 | "Please Enter the Pexels API Key": "Bitte geben Sie den **Pexels API-Schlüssel** ein", 83 | "Please Enter the Pixabay API Key": "Bitte geben Sie den **Pixabay API-Schlüssel** ein", 84 | "Get Help": "Wenn Sie Hilfe benötigen oder Fragen haben, können Sie dem Discord beitreten: https://harryai.cc", 85 | "Video Source": "Videoquelle", 86 | "TikTok": "TikTok (TikTok-Unterstützung kommt bald)", 87 | "Bilibili": "Bilibili (Bilibili-Unterstützung kommt bald)", 88 | "Xiaohongshu": "Xiaohongshu (Xiaohongshu-Unterstützung kommt bald)", 89 | "Local file": "Lokale Datei", 90 | "Play Voice": "Sprachausgabe abspielen", 91 | "Voice Example": "Dies ist ein Beispieltext zum Testen der Sprachsynthese", 92 | "Synthesizing Voice": "Sprachsynthese läuft, bitte warten...", 93 | "TTS Provider": "Sprachsynthese-Anbieter auswählen", 94 | "TTS Servers": "TTS-Server", 95 | "No voices available for the selected TTS server. Please select another server.": "Keine Stimmen für den ausgewählten TTS-Server verfügbar. Bitte wählen Sie einen anderen Server.", 96 | "SiliconFlow API Key": "SiliconFlow API-Schlüssel", 97 | "SiliconFlow TTS Settings": "SiliconFlow TTS-Einstellungen", 98 | "Speed: Range [0.25, 4.0], default is 1.0": "Geschwindigkeit: Bereich [0.25, 4.0], Standardwert ist 1.0", 99 | "Volume: Uses Speech Volume setting, default 1.0 maps to gain 0": "Lautstärke: Verwendet die Sprachlautstärke-Einstellung, Standardwert 1.0 entspricht Verstärkung 0", 100 | "Hide Log": "Protokoll ausblenden", 101 | "Hide Basic Settings": "Basis-Einstellungen ausblenden\n\nWenn diese Option deaktiviert ist, wird die Basis-Einstellungen-Leiste nicht auf der Seite angezeigt.\n\nWenn Sie sie erneut anzeigen möchten, setzen Sie `hide_config = false` in `config.toml`", 102 | "LLM Settings": "**LLM-Einstellungen**", 103 | "Video Source Settings": "**Videoquellen-Einstellungen**" 104 | } 105 | } -------------------------------------------------------------------------------- /webui/i18n/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "Language": "English", 3 | "Translation": { 4 | "Login Required": "Login Required", 5 | "Please login to access settings": "Please login to access settings", 6 | "Username": "Username", 7 | "Password": "Password", 8 | "Login": "Login", 9 | "Login Error": "Login Error", 10 | "Incorrect username or password": "Incorrect username or password", 11 | "Please enter your username and password": "Please enter your username and password", 12 | "Video Script Settings": "**Video Script Settings**", 13 | "Video Subject": "Video Subject (Provide a keyword, :red[AI will automatically generate] video script)", 14 | "Script Language": "Language for Generating Video Script (AI will automatically output based on the language of your subject)", 15 | "Generate Video Script and Keywords": "Click to use AI to generate [Video Script] and [Video Keywords] based on **subject**", 16 | "Auto Detect": "Auto Detect", 17 | "Video Script": "Video Script (:blue[① Optional, AI generated ② Proper punctuation helps with subtitle generation])", 18 | "Generate Video Keywords": "Click to use AI to generate [Video Keywords] based on **script**", 19 | "Please Enter the Video Subject": "Please Enter the Video Script First", 20 | "Generating Video Script and Keywords": "AI is generating video script and keywords...", 21 | "Generating Video Keywords": "AI is generating video keywords...", 22 | "Video Keywords": "Video Keywords (:blue[① Optional, AI generated ② Use **English commas** for separation, English only])", 23 | "Video Settings": "**Video Settings**", 24 | "Video Concat Mode": "Video Concatenation Mode", 25 | "Random": "Random Concatenation (Recommended)", 26 | "Sequential": "Sequential Concatenation", 27 | "Video Transition Mode": "Video Transition Mode", 28 | "None": "None", 29 | "Shuffle": "Shuffle", 30 | "FadeIn": "FadeIn", 31 | "FadeOut": "FadeOut", 32 | "SlideIn": "SlideIn", 33 | "SlideOut": "SlideOut", 34 | "Video Ratio": "Video Aspect Ratio", 35 | "Portrait": "Portrait 9:16", 36 | "Landscape": "Landscape 16:9", 37 | "Clip Duration": "Maximum Duration of Video Clips (seconds)", 38 | "Number of Videos Generated Simultaneously": "Number of Videos Generated Simultaneously", 39 | "Audio Settings": "**Audio Settings**", 40 | "Speech Synthesis": "Speech Synthesis Voice", 41 | "Speech Region": "Region(:red[Required,[Get Region](https://portal.azure.com/#view/Microsoft_Azure_ProjectOxford/CognitiveServicesHub/~/SpeechServices)])", 42 | "Speech Key": "API Key(:red[Required,[Get API Key](https://portal.azure.com/#view/Microsoft_Azure_ProjectOxford/CognitiveServicesHub/~/SpeechServices)])", 43 | "Speech Volume": "Speech Volume (1.0 represents 100%)", 44 | "Speech Rate": "Speech Rate (1.0 means 1x speed)", 45 | "Male": "Male", 46 | "Female": "Female", 47 | "Background Music": "Background Music", 48 | "No Background Music": "No Background Music", 49 | "Random Background Music": "Random Background Music", 50 | "Custom Background Music": "Custom Background Music", 51 | "Custom Background Music File": "Please enter the file path for custom background music:", 52 | "Background Music Volume": "Background Music Volume (0.2 represents 20%, background music should not be too loud)", 53 | "Subtitle Settings": "**Subtitle Settings**", 54 | "Enable Subtitles": "Enable Subtitles (If unchecked, the settings below will not take effect)", 55 | "Font": "Subtitle Font", 56 | "Position": "Subtitle Position", 57 | "Top": "Top", 58 | "Center": "Center", 59 | "Bottom": "Bottom (Recommended)", 60 | "Custom": "Custom position (70, indicating 70% down from the top)", 61 | "Font Size": "Subtitle Font Size", 62 | "Font Color": "Subtitle Font Color", 63 | "Stroke Color": "Subtitle Outline Color", 64 | "Stroke Width": "Subtitle Outline Width", 65 | "Generate Video": "Generate Video", 66 | "Video Script and Subject Cannot Both Be Empty": "Video Subject and Video Script cannot both be empty", 67 | "Generating Video": "Generating video, please wait...", 68 | "Start Generating Video": "Start Generating Video", 69 | "Video Generation Completed": "Video Generation Completed", 70 | "Video Generation Failed": "Video Generation Failed", 71 | "You can download the generated video from the following links": "You can download the generated video from the following links", 72 | "Pexels API Key": "Pexels API Key ([Get API Key](https://www.pexels.com/api/))", 73 | "Pixabay API Key": "Pixabay API Key ([Get API Key](https://pixabay.com/api/docs/#api_search_videos))", 74 | "Basic Settings": "**Basic Settings** (:blue[Click to expand])", 75 | "Language": "Language", 76 | "LLM Provider": "LLM Provider", 77 | "API Key": "API Key (:red[Required])", 78 | "Base Url": "Base Url", 79 | "Account ID": "Account ID (Get from Cloudflare dashboard)", 80 | "Model Name": "Model Name", 81 | "Please Enter the LLM API Key": "Please Enter the **LLM API Key**", 82 | "Please Enter the Pexels API Key": "Please Enter the **Pexels API Key**", 83 | "Please Enter the Pixabay API Key": "Please Enter the **Pixabay API Key**", 84 | "Get Help": "If you need help, or have any questions, you can join discord for help: https://harryai.cc", 85 | "Video Source": "Video Source", 86 | "TikTok": "TikTok (TikTok support is coming soon)", 87 | "Bilibili": "Bilibili (Bilibili support is coming soon)", 88 | "Xiaohongshu": "Xiaohongshu (Xiaohongshu support is coming soon)", 89 | "Local file": "Local file", 90 | "Play Voice": "Play Voice", 91 | "Voice Example": "This is an example text for testing speech synthesis", 92 | "Synthesizing Voice": "Synthesizing voice, please wait...", 93 | "TTS Provider": "Select the voice synthesis provider", 94 | "TTS Servers": "TTS Servers", 95 | "No voices available for the selected TTS server. Please select another server.": "No voices available for the selected TTS server. Please select another server.", 96 | "SiliconFlow API Key": "SiliconFlow API Key [Click to get](https://cloud.siliconflow.cn/account/ak)", 97 | "SiliconFlow TTS Settings": "SiliconFlow TTS Settings", 98 | "Speed: Range [0.25, 4.0], default is 1.0": "Speed: Range [0.25, 4.0], default is 1.0", 99 | "Volume: Uses Speech Volume setting, default 1.0 maps to gain 0": "Volume: Uses Speech Volume setting, default 1.0 maps to gain 0", 100 | "Hide Log": "Hide Log", 101 | "Hide Basic Settings": "Hide Basic Settings\n\nHidden, the basic settings panel will not be displayed on the page.\n\nIf you need to display it again, please set `hide_config = false` in `config.toml`", 102 | "LLM Settings": "**LLM Settings**", 103 | "Video Source Settings": "**Video Source Settings**" 104 | } 105 | } -------------------------------------------------------------------------------- /webui/i18n/pt.json: -------------------------------------------------------------------------------- 1 | { 2 | "Language": "Português Brasileiro", 3 | "Translation": { 4 | "Login Required": "Login Necessário", 5 | "Please login to access settings": "Por favor, faça login para acessar as configurações", 6 | "Username": "Nome de usuário", 7 | "Password": "Senha", 8 | "Login": "Entrar", 9 | "Login Error": "Erro de Login", 10 | "Incorrect username or password": "Nome de usuário ou senha incorretos", 11 | "Please enter your username and password": "Por favor, digite seu nome de usuário e senha", 12 | "Video Script Settings": "**Configurações do Roteiro do Vídeo**", 13 | "Video Subject": "Tema do Vídeo (Forneça uma palavra-chave, :red[a IA irá gerar automaticamente] o roteiro do vídeo)", 14 | "Script Language": "Idioma para Gerar o Roteiro do Vídeo (a IA irá gerar automaticamente com base no idioma do seu tema)", 15 | "Generate Video Script and Keywords": "Clique para usar a IA para gerar o [Roteiro do Vídeo] e as [Palavras-chave do Vídeo] com base no **tema**", 16 | "Auto Detect": "Detectar Automaticamente", 17 | "Video Script": "Roteiro do Vídeo (:blue[① Opcional, gerado pela IA ② Pontuação adequada ajuda na geração de legendas])", 18 | "Generate Video Keywords": "Clique para usar a IA para gerar [Palavras-chave do Vídeo] com base no **roteiro**", 19 | "Please Enter the Video Subject": "Por favor, insira o Roteiro do Vídeo primeiro", 20 | "Generating Video Script and Keywords": "A IA está gerando o roteiro do vídeo e as palavras-chave...", 21 | "Generating Video Keywords": "A IA está gerando as palavras-chave do vídeo...", 22 | "Video Keywords": "Palavras-chave do Vídeo (:blue[① Opcional, gerado pela IA ② Use **vírgulas em inglês** para separar, somente em inglês])", 23 | "Video Settings": "**Configurações do Vídeo**", 24 | "Video Concat Mode": "Modo de Concatenação de Vídeo", 25 | "Random": "Concatenação Aleatória (Recomendado)", 26 | "Sequential": "Concatenação Sequencial", 27 | "Video Transition Mode": "Modo de Transição de Vídeo", 28 | "None": "Nenhuma Transição", 29 | "Shuffle": "Transição Aleatória", 30 | "FadeIn": "FadeIn", 31 | "FadeOut": "FadeOut", 32 | "SlideIn": "SlideIn", 33 | "SlideOut": "SlideOut", 34 | "Video Ratio": "Proporção do Vídeo", 35 | "Portrait": "Retrato 9:16", 36 | "Landscape": "Paisagem 16:9", 37 | "Clip Duration": "Duração Máxima dos Clipes de Vídeo (segundos)", 38 | "Number of Videos Generated Simultaneously": "Número de Vídeos Gerados Simultaneamente", 39 | "Audio Settings": "**Configurações de Áudio**", 40 | "Speech Synthesis": "Voz de Síntese de Fala", 41 | "Speech Region": "Região(:red[Obrigatório,[Obter Região](https://portal.azure.com/#view/Microsoft_Azure_ProjectOxford/CognitiveServicesHub/~/SpeechServices)])", 42 | "Speech Key": "Chave da API(:red[Obrigatório,[Obter Chave da API](https://portal.azure.com/#view/Microsoft_Azure_ProjectOxford/CognitiveServicesHub/~/SpeechServices)])", 43 | "Speech Volume": "Volume da Fala (1.0 representa 100%)", 44 | "Speech Rate": "Velocidade da Fala (1.0 significa velocidade 1x)", 45 | "Male": "Masculino", 46 | "Female": "Feminino", 47 | "Background Music": "Música de Fundo", 48 | "No Background Music": "Sem Música de Fundo", 49 | "Random Background Music": "Música de Fundo Aleatória", 50 | "Custom Background Music": "Música de Fundo Personalizada", 51 | "Custom Background Music File": "Por favor, insira o caminho do arquivo para a música de fundo personalizada:", 52 | "Background Music Volume": "Volume da Música de Fundo (0.2 representa 20%, a música de fundo não deve ser muito alta)", 53 | "Subtitle Settings": "**Configurações de Legendas**", 54 | "Enable Subtitles": "Ativar Legendas (Se desmarcado, as configurações abaixo não terão efeito)", 55 | "Font": "Fonte da Legenda", 56 | "Position": "Posição da Legenda", 57 | "Top": "Superior", 58 | "Center": "Centralizar", 59 | "Bottom": "Inferior (Recomendado)", 60 | "Custom": "Posição personalizada (70, indicando 70% abaixo do topo)", 61 | "Font Size": "Tamanho da Fonte da Legenda", 62 | "Font Color": "Cor da Fonte da Legenda", 63 | "Stroke Color": "Cor do Contorno da Legenda", 64 | "Stroke Width": "Largura do Contorno da Legenda", 65 | "Generate Video": "Gerar Vídeo", 66 | "Video Script and Subject Cannot Both Be Empty": "O Tema do Vídeo e o Roteiro do Vídeo não podem estar ambos vazios", 67 | "Generating Video": "Gerando vídeo, por favor aguarde...", 68 | "Start Generating Video": "Começar a Gerar Vídeo", 69 | "Video Generation Completed": "Geração do Vídeo Concluída", 70 | "Video Generation Failed": "Falha na Geração do Vídeo", 71 | "You can download the generated video from the following links": "Você pode baixar o vídeo gerado a partir dos seguintes links", 72 | "Basic Settings": "**Configurações Básicas** (:blue[Clique para expandir])", 73 | "Language": "Idioma", 74 | "Pexels API Key": "Chave da API do Pexels ([Obter Chave da API](https://www.pexels.com/api/))", 75 | "Pixabay API Key": "Chave da API do Pixabay ([Obter Chave da API](https://pixabay.com/api/docs/#api_search_videos))", 76 | "LLM Provider": "Provedor LLM", 77 | "API Key": "Chave da API (:red[Obrigatório])", 78 | "Base Url": "URL Base", 79 | "Account ID": "ID da Conta (Obter no painel do Cloudflare)", 80 | "Model Name": "Nome do Modelo", 81 | "Please Enter the LLM API Key": "Por favor, insira a **Chave da API LLM**", 82 | "Please Enter the Pexels API Key": "Por favor, insira a **Chave da API do Pexels**", 83 | "Please Enter the Pixabay API Key": "Por favor, insira a **Chave da API do Pixabay**", 84 | "Get Help": "Se precisar de ajuda ou tiver alguma dúvida, você pode entrar no discord para obter ajuda: https://harryai.cc", 85 | "Video Source": "Fonte do Vídeo", 86 | "TikTok": "TikTok (Suporte para TikTok em breve)", 87 | "Bilibili": "Bilibili (Suporte para Bilibili em breve)", 88 | "Xiaohongshu": "Xiaohongshu (Suporte para Xiaohongshu em breve)", 89 | "Local file": "Arquivo local", 90 | "Play Voice": "Reproduzir Voz", 91 | "Voice Example": "Este é um exemplo de texto para testar a síntese de fala", 92 | "Synthesizing Voice": "Sintetizando voz, por favor aguarde...", 93 | "TTS Provider": "Selecione o provedor de síntese de voz", 94 | "TTS Servers": "Servidores TTS", 95 | "No voices available for the selected TTS server. Please select another server.": "Não há vozes disponíveis para o servidor TTS selecionado. Por favor, selecione outro servidor.", 96 | "SiliconFlow API Key": "Chave API do SiliconFlow", 97 | "SiliconFlow TTS Settings": "Configurações do SiliconFlow TTS", 98 | "Speed: Range [0.25, 4.0], default is 1.0": "Velocidade: Intervalo [0.25, 4.0], o padrão é 1.0", 99 | "Volume: Uses Speech Volume setting, default 1.0 maps to gain 0": "Volume: Usa a configuração de Volume de Fala, o padrão 1.0 corresponde ao ganho 0", 100 | "Hide Log": "Ocultar Log", 101 | "Hide Basic Settings": "Ocultar Configurações Básicas\n\nOculto, o painel de configurações básicas não será exibido na página.\n\nSe precisar exibi-lo novamente, defina `hide_config = false` em `config.toml`", 102 | "LLM Settings": "**Configurações do LLM**", 103 | "Video Source Settings": "**Configurações da Fonte do Vídeo**" 104 | } 105 | } -------------------------------------------------------------------------------- /webui/i18n/vi.json: -------------------------------------------------------------------------------- 1 | { 2 | "Language": "Tiếng Việt", 3 | "Translation": { 4 | "Login Required": "Yêu cầu đăng nhập", 5 | "Please login to access settings": "Vui lòng đăng nhập để truy cập cài đặt", 6 | "Username": "Tên đăng nhập", 7 | "Password": "Mật khẩu", 8 | "Login": "Đăng nhập", 9 | "Login Error": "Lỗi đăng nhập", 10 | "Incorrect username or password": "Tên đăng nhập hoặc mật khẩu không chính xác", 11 | "Please enter your username and password": "Vui lòng nhập tên đăng nhập và mật khẩu của bạn", 12 | "Video Script Settings": "**Cài Đặt Kịch Bản Video**", 13 | "Video Subject": "Chủ Đề Video (Cung cấp một từ khóa, :red[AI sẽ tự động tạo ra] kịch bản video)", 14 | "Script Language": "Ngôn Ngữ cho Việc Tạo Kịch Bản Video (AI sẽ tự động xuất ra dựa trên ngôn ngữ của chủ đề của bạn)", 15 | "Generate Video Script and Keywords": "Nhấn để sử dụng AI để tạo [Kịch Bản Video] và [Từ Khóa Video] dựa trên **chủ đề**", 16 | "Auto Detect": "Tự Động Phát Hiện", 17 | "Video Script": "Kịch Bản Video (:blue[① Tùy chọn, AI tạo ra ② Dấu câu chính xác giúp việc tạo phụ đề)", 18 | "Generate Video Keywords": "Nhấn để sử dụng AI để tạo [Từ Khóa Video] dựa trên **kịch bản**", 19 | "Please Enter the Video Subject": "Vui lòng Nhập Kịch Bản Video Trước", 20 | "Generating Video Script and Keywords": "AI đang tạo kịch bản video và từ khóa...", 21 | "Generating Video Keywords": "AI đang tạo từ khóa video...", 22 | "Video Keywords": "Từ Khóa Video (:blue[① Tùy chọn, AI tạo ra ② Sử dụng dấu phẩy **Tiếng Anh** để phân tách, chỉ sử dụng Tiếng Anh])", 23 | "Video Settings": "**Cài Đặt Video**", 24 | "Video Concat Mode": "Chế Độ Nối Video", 25 | "Random": "Nối Ngẫu Nhiên (Được Khuyến Nghị)", 26 | "Sequential": "Nối Theo Thứ Tự", 27 | "Video Transition Mode": "Chế Độ Chuyển Đổi Video", 28 | "None": "Không Có Chuyển Đổi", 29 | "Shuffle": "Chuyển Đổi Ngẫu Nhiên", 30 | "FadeIn": "FadeIn", 31 | "FadeOut": "FadeOut", 32 | "SlideIn": "SlideIn", 33 | "SlideOut": "SlideOut", 34 | "Video Ratio": "Tỷ Lệ Khung Hình Video", 35 | "Portrait": "Dọc 9:16", 36 | "Landscape": "Ngang 16:9", 37 | "Clip Duration": "Thời Lượng Tối Đa Của Đoạn Video (giây)", 38 | "Number of Videos Generated Simultaneously": "Số Video Được Tạo Ra Đồng Thời", 39 | "Audio Settings": "**Cài Đặt Âm Thanh**", 40 | "Speech Synthesis": "Giọng Đọc Văn Bản", 41 | "Speech Region": "Vùng(:red[Bắt Buộc,[Lấy Vùng](https://portal.azure.com/#view/Microsoft_Azure_ProjectOxford/CognitiveServicesHub/~/SpeechServices)])", 42 | "Speech Key": "Khóa API(:red[Bắt Buộc,[Lấy Khóa API](https://portal.azure.com/#view/Microsoft_Azure_ProjectOxford/CognitiveServicesHub/~/SpeechServices)])", 43 | "Speech Volume": "Âm Lượng Giọng Đọc (1.0 đại diện cho 100%)", 44 | "Speech Rate": "Tốc độ đọc (1.0 biểu thị tốc độ gốc)", 45 | "Male": "Nam", 46 | "Female": "Nữ", 47 | "Background Music": "Âm Nhạc Nền", 48 | "No Background Music": "Không Có Âm Nhạc Nền", 49 | "Random Background Music": "Âm Nhạc Nền Ngẫu Nhiên", 50 | "Custom Background Music": "Âm Nhạc Nền Tùy Chỉnh", 51 | "Custom Background Music File": "Vui lòng nhập đường dẫn tệp cho âm nhạc nền tùy chỉnh:", 52 | "Background Music Volume": "Âm Lượng Âm Nhạc Nền (0.2 đại diện cho 20%, âm nhạc nền không nên quá to)", 53 | "Subtitle Settings": "**Cài Đặt Phụ Đề**", 54 | "Enable Subtitles": "Bật Phụ Đề (Nếu không chọn, các cài đặt dưới đây sẽ không có hiệu lực)", 55 | "Font": "Phông Chữ Phụ Đề", 56 | "Position": "Vị Trí Phụ Đề", 57 | "Top": "Trên", 58 | "Center": "Giữa", 59 | "Bottom": "Dưới (Được Khuyến Nghị)", 60 | "Custom": "Vị trí tùy chỉnh (70, chỉ ra là cách đầu trang 70%)", 61 | "Font Size": "Cỡ Chữ Phụ Đề", 62 | "Font Color": "Màu Chữ Phụ Đề", 63 | "Stroke Color": "Màu Viền Phụ Đề", 64 | "Stroke Width": "Độ Rộng Viền Phụ Đề", 65 | "Generate Video": "Tạo Video", 66 | "Video Script and Subject Cannot Both Be Empty": "Chủ Đề Video và Kịch Bản Video không thể cùng trống", 67 | "Generating Video": "Đang tạo video, vui lòng đợi...", 68 | "Start Generating Video": "Bắt Đầu Tạo Video", 69 | "Video Generation Completed": "Hoàn Tất Tạo Video", 70 | "Video Generation Failed": "Tạo Video Thất Bại", 71 | "You can download the generated video from the following links": "Bạn có thể tải video được tạo ra từ các liên kết sau", 72 | "Basic Settings": "**Cài Đặt Cơ Bản** (:blue[Nhấp để mở rộng])", 73 | "Language": "Ngôn Ngữ", 74 | "Pexels API Key": "Khóa API Pexels ([Lấy Khóa API](https://www.pexels.com/api/))", 75 | "Pixabay API Key": "Khóa API Pixabay ([Lấy Khóa API](https://pixabay.com/api/docs/#api_search_videos))", 76 | "LLM Provider": "Nhà Cung Cấp LLM", 77 | "API Key": "Khóa API (:red[Bắt Buộc])", 78 | "Base Url": "Url Cơ Bản", 79 | "Account ID": "ID Tài Khoản (Lấy từ bảng điều khiển Cloudflare)", 80 | "Model Name": "Tên Mô Hình", 81 | "Please Enter the LLM API Key": "Vui lòng Nhập **Khóa API LLM**", 82 | "Please Enter the Pexels API Key": "Vui lòng Nhập **Khóa API Pexels**", 83 | "Please Enter the Pixabay API Key": "Vui lòng Nhập **Khóa API Pixabay**", 84 | "Get Help": "Nếu bạn cần giúp đỡ hoặc có bất kỳ câu hỏi nào, bạn có thể tham gia discord để được giúp đỡ: https://harryai.cc", 85 | "Video Source": "Nguồn Video", 86 | "TikTok": "TikTok (Hỗ trợ TikTok sắp ra mắt)", 87 | "Bilibili": "Bilibili (Hỗ trợ Bilibili sắp ra mắt)", 88 | "Xiaohongshu": "Xiaohongshu (Hỗ trợ Xiaohongshu sắp ra mắt)", 89 | "Local file": "Tệp cục bộ", 90 | "Play Voice": "Phát Giọng Nói", 91 | "Voice Example": "Đây là văn bản mẫu để kiểm tra tổng hợp giọng nói", 92 | "Synthesizing Voice": "Đang tổng hợp giọng nói, vui lòng đợi...", 93 | "TTS Provider": "Chọn nhà cung cấp tổng hợp giọng nói", 94 | "TTS Servers": "Máy chủ TTS", 95 | "No voices available for the selected TTS server. Please select another server.": "Không có giọng nói nào cho máy chủ TTS đã chọn. Vui lòng chọn máy chủ khác.", 96 | "SiliconFlow API Key": "Khóa API SiliconFlow", 97 | "SiliconFlow TTS Settings": "Cài đặt SiliconFlow TTS", 98 | "Speed: Range [0.25, 4.0], default is 1.0": "Tốc độ: Phạm vi [0.25, 4.0], mặc định là 1.0", 99 | "Volume: Uses Speech Volume setting, default 1.0 maps to gain 0": "Âm lượng: Sử dụng cài đặt Âm lượng Giọng nói, mặc định 1.0 tương ứng với tăng ích 0", 100 | "Hide Log": "Ẩn Nhật Ký", 101 | "Hide Basic Settings": "Ẩn Cài Đặt Cơ Bản\n\nẨn, thanh cài đặt cơ bản sẽ không hiển thị trên trang web.\n\nNếu bạn muốn hiển thị lại, vui lòng đặt `hide_config = false` trong `config.toml`", 102 | "LLM Settings": "**Cài Đặt LLM**", 103 | "Video Source Settings": "**Cài Đặt Nguồn Video**" 104 | } 105 | } -------------------------------------------------------------------------------- /webui/i18n/zh.json: -------------------------------------------------------------------------------- 1 | { 2 | "Language": "简体中文", 3 | "Translation": { 4 | "Login Required": "需要登录", 5 | "Please login to access settings": "请登录后访问配置设置 (:gray[默认用户名: admin, 密码: admin, 您可以在 config.toml 中修改])", 6 | "Username": "用户名", 7 | "Password": "密码", 8 | "Login": "登录", 9 | "Login Error": "登录错误", 10 | "Incorrect username or password": "用户名或密码不正确", 11 | "Please enter your username and password": "请输入用户名和密码", 12 | "Video Script Settings": "**文案设置**", 13 | "Video Subject": "视频主题(给定一个关键词,:red[AI自动生成]视频文案)", 14 | "Script Language": "生成视频脚本的语言(一般情况AI会自动根据你输入的主题语言输出)", 15 | "Generate Video Script and Keywords": "点击使用AI根据**主题**生成 【视频文案】 和 【视频关键词】", 16 | "Auto Detect": "自动检测", 17 | "Video Script": "视频文案(:blue[①可不填,使用AI生成 ②合理使用标点断句,有助于生成字幕])", 18 | "Generate Video Keywords": "点击使用AI根据**文案**生成【视频关键词】", 19 | "Please Enter the Video Subject": "请先填写视频文案", 20 | "Generating Video Script and Keywords": "AI正在生成视频文案和关键词...", 21 | "Generating Video Keywords": "AI正在生成视频关键词...", 22 | "Video Keywords": "视频关键词(:blue[①可不填,使用AI生成 ②用**英文逗号**分隔,只支持英文])", 23 | "Video Settings": "**视频设置**", 24 | "Video Concat Mode": "视频拼接模式", 25 | "Random": "随机拼接(推荐)", 26 | "Sequential": "顺序拼接", 27 | "Video Transition Mode": "视频转场模式", 28 | "None": "无转场", 29 | "Shuffle": "随机转场", 30 | "FadeIn": "渐入", 31 | "FadeOut": "渐出", 32 | "SlideIn": "滑动入", 33 | "SlideOut": "滑动出", 34 | "Video Ratio": "视频比例", 35 | "Portrait": "竖屏 9:16(抖音视频)", 36 | "Landscape": "横屏 16:9(西瓜视频)", 37 | "Clip Duration": "视频片段最大时长(秒)(**不是视频总长度**,是指每个**合成片段**的长度)", 38 | "Number of Videos Generated Simultaneously": "同时生成视频数量", 39 | "Audio Settings": "**音频设置**", 40 | "Speech Synthesis": "朗读声音(:red[**与文案语言保持一致**。注意:V2版效果更好,但是需要API KEY])", 41 | "Speech Region": "服务区域 (:red[必填,[点击获取](https://portal.azure.com/#view/Microsoft_Azure_ProjectOxford/CognitiveServicesHub/~/SpeechServices)])", 42 | "Speech Key": "API Key (:red[必填,密钥1 或 密钥2 均可 [点击获取](https://portal.azure.com/#view/Microsoft_Azure_ProjectOxford/CognitiveServicesHub/~/SpeechServices)])", 43 | "Speech Volume": "朗读音量(1.0表示100%)", 44 | "Speech Rate": "朗读速度(1.0表示1倍速)", 45 | "Male": "男性", 46 | "Female": "女性", 47 | "Background Music": "背景音乐", 48 | "No Background Music": "无背景音乐", 49 | "Random Background Music": "随机背景音乐", 50 | "Custom Background Music": "自定义背景音乐", 51 | "Custom Background Music File": "请输入自定义背景音乐的文件路径", 52 | "Background Music Volume": "背景音乐音量(0.2表示20%,背景声音不宜过高)", 53 | "Subtitle Settings": "**字幕设置**", 54 | "Enable Subtitles": "启用字幕(若取消勾选,下面的设置都将不生效)", 55 | "Font": "字幕字体", 56 | "Position": "字幕位置", 57 | "Top": "顶部", 58 | "Center": "中间", 59 | "Bottom": "底部(推荐)", 60 | "Custom": "自定义位置(70,表示离顶部70%的位置)", 61 | "Font Size": "字幕大小", 62 | "Font Color": "字幕颜色", 63 | "Stroke Color": "描边颜色", 64 | "Stroke Width": "描边粗细", 65 | "Generate Video": "生成视频", 66 | "Video Script and Subject Cannot Both Be Empty": "视频主题 和 视频文案,不能同时为空", 67 | "Generating Video": "正在生成视频,请稍候...", 68 | "Start Generating Video": "开始生成视频", 69 | "Video Generation Completed": "视频生成完成", 70 | "Video Generation Failed": "视频生成失败", 71 | "You can download the generated video from the following links": "你可以从以下链接下载生成的视频", 72 | "Basic Settings": "**基础设置** (:blue[点击展开])", 73 | "Language": "界面语言", 74 | "Pexels API Key": "Pexels API Key ([点击获取](https://www.pexels.com/api/)) :red[推荐使用]", 75 | "Pixabay API Key": "Pixabay API Key ([点击获取](https://pixabay.com/api/docs/#api_search_videos)) :red[可以不用配置,如果 Pexels 无法使用,再选择Pixabay]", 76 | "LLM Provider": "大模型提供商", 77 | "API Key": "API Key (:red[必填,需要到大模型提供商的后台申请])", 78 | "Base Url": "Base Url (可选)", 79 | "Account ID": "账户ID (Cloudflare的dash面板url中获取)", 80 | "Model Name": "模型名称 (:blue[需要到大模型提供商的后台确认被授权的模型名称])", 81 | "Please Enter the LLM API Key": "请先填写大模型 **API Key**", 82 | "Please Enter the Pexels API Key": "请先填写 **Pexels API Key**", 83 | "Please Enter the Pixabay API Key": "请先填写 **Pixabay API Key**", 84 | "Get Help": "有任何问题或建议,可以加入 **微信群** 求助或讨论:https://harryai.cc", 85 | "Video Source": "视频来源", 86 | "TikTok": "抖音 (TikTok 支持中,敬请期待)", 87 | "Bilibili": "哔哩哔哩 (Bilibili 支持中,敬请期待)", 88 | "Xiaohongshu": "小红书 (Xiaohongshu 支持中,敬请期待)", 89 | "Local file": "本地文件", 90 | "Play Voice": "试听语音合成", 91 | "Voice Example": "这是一段测试语音合成的示例文本", 92 | "Synthesizing Voice": "语音合成中,请稍候...", 93 | "TTS Provider": "语音合成提供商", 94 | "TTS Servers": "TTS服务器", 95 | "No voices available for the selected TTS server. Please select another server.": "当前选择的TTS服务器没有可用的声音,请选择其他服务器。", 96 | "SiliconFlow API Key": "硅基流动API密钥 [点击获取](https://cloud.siliconflow.cn/account/ak)", 97 | "SiliconFlow TTS Settings": "硅基流动TTS设置", 98 | "Speed: Range [0.25, 4.0], default is 1.0": "语速范围 [0.25, 4.0],默认值为1.0", 99 | "Volume: Uses Speech Volume setting, default 1.0 maps to gain 0": "音量:使用朗读音量设置,默认值1.0对应增益0", 100 | "Hide Log": "隐藏日志", 101 | "Hide Basic Settings": "隐藏基础设置\n\n隐藏后,基础设置面板将不会显示在页面中。\n\n如需要再次显示,请在 `config.toml` 中设置 `hide_config = false`", 102 | "LLM Settings": "**大模型设置**", 103 | "Video Source Settings": "**视频源设置**" 104 | } 105 | } --------------------------------------------------------------------------------