diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..aae9b9a --- /dev/null +++ b/.dockerignore @@ -0,0 +1,23 @@ +# Exclude common Python files and directories +venv/ +__pycache__/ +*.pyc +*.pyo +*.pyd +*.pyz +*.pyw +*.pyi +*.egg-info/ + +# Exclude development and local files +.env +.env.* +*.log +*.db + +# Exclude version control system files +.git/ +.gitignore +.svn/ + +storage/ diff --git a/.gitignore b/.gitignore index cde3a7e..c0c10e6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,10 @@ +.DS_Store /config.toml /storage/ -/.idea/ \ No newline at end of file +/.idea/ +/app/services/__pycache__ +/app/__pycache__/ +/app/config/__pycache__/ +/app/models/__pycache__/ +/app/utils/__pycache__/ +/*/__pycache__/* \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..bd871c6 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,37 @@ +# Use an official Python runtime as a parent image +FROM python:3.10-slim + +# Set the working directory in the container +WORKDIR /MoneyPrinterTurbo + +ENV PYTHONPATH="/MoneyPrinterTurbo:$PYTHONPATH" + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + git \ + imagemagick \ + ffmpeg \ + && rm -rf /var/lib/apt/lists/* + +# Fix security policy for ImageMagick +RUN sed -i '/ +

MoneyPrinterTurbo 💸

+ +

+ Stargazers + Issues + Forks + License +

+ +

English | 简体中文

-[Chinese 简体中文](README.md) > Thanks to [RootFTW](https://github.com/Root-FTW) for the translation -Simply provide a **topic** or **keyword** for a video, and it will automatically generate the video copy, video +Simply provide a topic or keyword for a video, and it will automatically generate the video copy, video materials, video subtitles, and video background music before synthesizing a high-definition short video. -![](docs/webui.jpg) +### WebUI + +![](docs/webui-en.jpg) + +### API Interface + +![](docs/api.jpg) + + ## Special Thanks 🙏 Due to the **deployment** and **usage** of this project, there is a certain threshold for some beginner users. We would like to express our special thanks to -**LuKa (AI Intelligent Multimedia Service Platform)** for providing a free `AI Video Generator` service based on this +**RecCloud (AI-Powered Multimedia Service Platform)** for providing a free `AI Video Generator` service based on this project. It allows for online use without deployment, which is very convenient. https://reccloud.com @@ -24,7 +41,8 @@ https://reccloud.com ## Features 🎯 -- [x] Complete **MVC architecture**, **clearly structured** code, easy to maintain, supports both API and Web interface +- [x] Complete **MVC architecture**, **clearly structured** code, easy to maintain, supports both `API` + and `Web interface` - [x] Supports **AI-generated** video copy, as well as **customized copy** - [x] Supports various **high-definition video** sizes - [x] Portrait 9:16, `1080x1920` @@ -38,42 +56,108 @@ https://reccloud.com supports `subtitle outlining` - [x] Supports **background music**, either random or specified music files, with adjustable `background music volume` - [x] Video material sources are **high-definition** and **royalty-free** -- [x] Supports integration with various models such as **OpenAI**, **moonshot**, **Azure**, **gpt4free**, **one-api**, * - *qianwen** - and more +- [x] Supports integration with various models such as **OpenAI**, **moonshot**, **Azure**, **gpt4free**, **one-api**, + **qianwen**, **Google Gemini** and more ### Future Plans 📅 -- [ ] Support for GPT-SoVITS dubbing -- [ ] Optimize voice synthesis using large models to make the synthesized voice sound more natural and emotionally rich -- [ ] Add video transition effects to make the viewing experience smoother -- [ ] Optimize the relevance of video materials -- [ ] OLLAMA support +- [ ] Introduce support for GPT-SoVITS dubbing +- [ ] Enhance voice synthesis with large models for a more natural and emotionally resonant voice output +- [ ] Incorporate video transition effects to ensure a smoother viewing experience +- [ ] Improve the relevance of video content +- [ ] Implement OLLAMA support +- [ ] Add options for video length: short, medium, long +- [ ] Package the application into a one-click launch bundle for Windows and macOS for ease of use +- [ ] Enable the use of custom materials +- [ ] Offer voiceover and background music options with real-time preview +- [ ] Support a wider range of voice synthesis providers, such as OpenAI TTS +- [ ] Automate the upload process to the YouTube platform ## Video Demos 📺 ### Portrait 9:16 -▶️ How to Add Fun to Your Life - -https://github.com/harry0703/MoneyPrinterTurbo/assets/4928832/a84d33d5-27a2-4aba-8fd0-9fb2bd91c6a6 - -▶️ What is the Meaning of Life - -https://github.com/harry0703/MoneyPrinterTurbo/assets/4928832/112c9564-d52b-4472-99ad-970b75f66476 + + + + + + + + + + + + + +
▶️ How to Add Fun to Your Life ▶️ What is the Meaning of Life
### Landscape 16:9 -▶️ What is the Meaning of Life - -https://github.com/harry0703/MoneyPrinterTurbo/assets/4928832/346ebb15-c55f-47a9-a653-114f08bb8073 - -▶️ Why Exercise - -https://github.com/harry0703/MoneyPrinterTurbo/assets/4928832/271f2fae-8283-44a0-8aa0-0ed8f9a6fa87 + + + + + + + + + + + + + +
▶️ What is the Meaning of Life▶️ Why Exercise
## Installation & Deployment 📥 +- Try to avoid using **Chinese paths** to prevent unpredictable issues +- Ensure your **network** is stable, meaning you can access foreign websites normally + +#### ① Clone the Project + +```shell +git clone https://github.com/harry0703/MoneyPrinterTurbo.git +``` + +#### ② Modify the Configuration File + +- Copy the `config.example.toml` file and rename it to `config.toml` +- Follow the instructions in the `config.toml` file to configure `pexels_api_keys` and `llm_provider`, and according to + the llm_provider's service provider, set up the corresponding API Key + +#### ③ Configure Large Language Models (LLM) + +- To use `GPT-4.0` or `GPT-3.5`, you need an `API Key` from `OpenAI`. If you don't have one, you can set `llm_provider` + to `g4f` (a free-to-use GPT library https://github.com/xtekky/gpt4free) + +### Docker Deployment 🐳 + +#### ① Launch the Docker Container + +If you haven't installed Docker, please install it first https://www.docker.com/products/docker-desktop/ +If you are using a Windows system, please refer to Microsoft's documentation: + +1. https://learn.microsoft.com/en-us/windows/wsl/install +2. https://learn.microsoft.com/en-us/windows/wsl/tutorials/wsl-containers + +```shell +cd MoneyPrinterTurbo +docker-compose up +``` + +#### ② Access the Web Interface + +Open your browser and visit http://0.0.0.0:8501 + +#### ③ Access the API Interface + +Open your browser and visit http://0.0.0.0:8080/docs Or http://0.0.0.0:8080/redoc + +### Manual Deployment 📦 + +#### ① Create a Python Virtual Environment + It is recommended to create a Python virtual environment using [conda](https://conda.io/projects/conda/en/latest/user-guide/install/index.html) @@ -85,71 +169,45 @@ conda activate MoneyPrinterTurbo pip install -r requirements.txt ``` -## Quick Start 🚀 +#### ② Install ImageMagick -### Video Tutorials - -- Complete usage demonstration: https://v.douyin.com/iFhnwsKY/ -- How to deploy on Windows: https://v.douyin.com/iFyjoW3M - -### Prerequisites - -- Try to avoid using **Chinese paths** to prevent unpredictable issues -- Ensure your **network** is stable, meaning you can access foreign websites normally - -#### ① Install ImageMagick - -##### Windows: +###### Windows: - Download https://imagemagick.org/archive/binaries/ImageMagick-7.1.1-29-Q16-x64-static.exe -- Install the downloaded ImageMagick, do not change the installation path +- Install the downloaded ImageMagick, **do not change the installation path** +- Modify the `config.toml` configuration file, set `imagemagick_path` to your actual installation path (if you didn't + change the path during installation, just uncomment it) -##### MacOS: +###### MacOS: ```shell brew install imagemagick ```` -##### Ubuntu +###### Ubuntu ```shell sudo apt-get install imagemagick ``` -##### CentOS +###### CentOS ```shell sudo yum install ImageMagick ``` -#### ② Modify the Configuration File - -- Copy the `config.example.toml` file and rename it to `config.toml` -- Follow the instructions in the `config.toml` file to configure `pexels_api_keys` and `llm_provider`, and according to - the llm_provider's service provider, set up the corresponding API Key -- If it's a `Windows` system, `imagemagick_path` is your actual installation path (if you didn't change the path during - installation, just uncomment it) - -#### ③ Configure Large Language Models (LLM) - -- To use `GPT-4.0` or `GPT-3.5`, you need an `API Key` from `OpenAI`. If you don't have one, you can set `llm_provider` - to `g4f` (a free-to-use GPT library https://github.com/xtekky/gpt4free) -- Alternatively, you can apply at [Moonshot](https://platform.moonshot.cn/console/api-keys). Register to get 15 yuan of - trial money, which allows for about 1500 conversations. Then set `llm_provider="moonshot"` and `moonshot_api_key`. - Thanks to [@jerryblues](https://github.com/harry0703/MoneyPrinterTurbo/issues/8) for the suggestion - -### Launch the Web Interface 🌐 +#### ③ Launch the Web Interface 🌐 Note that you need to execute the following commands in the `root directory` of the MoneyPrinterTurbo project -#### Windows +###### Windows ```bat conda activate MoneyPrinterTurbo webui.bat ``` -#### MacOS or Linux +###### MacOS or Linux ```shell conda activate MoneyPrinterTurbo @@ -158,10 +216,7 @@ sh webui.sh After launching, the browser will open automatically -The effect is shown in the following image: -![](docs/webui.jpg) - -### Launch the API Service 🚀 +#### ④ Launch the API Service 🚀 ```shell python main.py @@ -170,9 +225,6 @@ python main.py After launching, you can view the `API documentation` at http://127.0.0.1:8080/docs and directly test the interface online for a quick experience. -The effect is shown in the following image: -![](docs/api.jpg) - ## Voice Synthesis 🗣 A list of all supported voices can be viewed here: [Voice List](./docs/voice-list.txt) @@ -270,3 +322,7 @@ optimizations and added functionalities. Thanks to the original author for their ## License 📝 Click to view the [`LICENSE`](LICENSE) file + +## Star History + +[![Star History Chart](https://api.star-history.com/svg?repos=harry0703/MoneyPrinterTurbo&type=Date)](https://star-history.com/#harry0703/MoneyPrinterTurbo&Date) \ No newline at end of file diff --git a/README.md b/README.md index 6e70077..46fd699 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,28 @@ -# MoneyPrinterTurbo 💸 +
+

MoneyPrinterTurbo 💸

-[English](README-en.md) +

+ Stargazers + Issues + Forks + License +

+
+

简体中文 | English

+
+只需提供一个视频 主题关键词 ,就可以全自动生成视频文案、视频素材、视频字幕、视频背景音乐,然后合成一个高清的短视频。 +
-只需提供一个视频 **主题** 或 **关键词** ,就可以全自动生成视频文案、视频素材、视频字幕、视频背景音乐,然后合成一个高清的短视频。 +

Web界面

![](docs/webui.jpg) +

API界面

+ +![](docs/api.jpg) + +
+ ## 特别感谢 🙏 由于该项目的 **部署** 和 **使用**,对于一些小白用户来说,还是 **有一定的门槛**,在此特别感谢 @@ -19,7 +36,7 @@ ## 功能特性 🎯 -- [x] 完整的 **MVC架构**,代码 **结构清晰**,易于维护,支持API和Web界面 +- [x] 完整的 **MVC架构**,代码 **结构清晰**,易于维护,支持 `API` 和 `Web界面` - [x] 支持视频文案 **AI自动生成**,也可以**自定义文案** - [x] 支持多种 **高清视频** 尺寸 - [x] 竖屏 9:16,`1080x1920` @@ -31,40 +48,116 @@ - [x] 支持 **字幕生成**,可以调整 `字体`、`位置`、`颜色`、`大小`,同时支持`字幕描边`设置 - [x] 支持 **背景音乐**,随机或者指定音乐文件,可设置`背景音乐音量` - [x] 视频素材来源 **高清**,而且 **无版权** -- [x] 支持 **OpenAI**、**moonshot**、**Azure**、**gpt4free**、**one-api**、**通义千问** 等多种模型接入 +- [x] 支持 **OpenAI**、**moonshot**、**Azure**、**gpt4free**、**one-api**、**通义千问**、**Google Gemini** 等多种模型接入 ### 后期计划 📅 - [ ] GPT-SoVITS 配音支持 - [ ] 优化语音合成,利用大模型,使其合成的声音,更加自然,情绪更加丰富 - [ ] 增加视频转场效果,使其看起来更加的流畅 -- [ ] 优化视频素材的匹配度 +- [ ] 增加更多视频素材来源,优化视频素材和文案的匹配度 - [ ] OLLAMA 支持 +- [ ] 增加视频长度选项:短、中、长 +- [ ] 打包成一键启动包(Windows,macOS),方便使用 +- [ ] 增加免费网络代理,让访问OpenAI和素材下载不再受限 +- [ ] 可以使用自己的素材 +- [ ] 朗读声音和背景音乐,提供实时试听 +- [ ] 支持更多的语音合成服务商,比如 OpenAI TTS +- [ ] 自动上传到YouTube平台 ## 视频演示 📺 ### 竖屏 9:16 -▶️ 《如何增加生活的乐趣》 - -https://github.com/harry0703/MoneyPrinterTurbo/assets/4928832/a84d33d5-27a2-4aba-8fd0-9fb2bd91c6a6 - -▶️ 《生命的意义是什么》 - -https://github.com/harry0703/MoneyPrinterTurbo/assets/4928832/112c9564-d52b-4472-99ad-970b75f66476 + + + + + + + + + + + + + +
▶️ 《如何增加生活的乐趣》▶️ 《生命的意义是什么》
### 横屏 16:9 -▶️《生命的意义是什么》 - -https://github.com/harry0703/MoneyPrinterTurbo/assets/4928832/346ebb15-c55f-47a9-a653-114f08bb8073 - -▶️《为什么要运动》 - -https://github.com/harry0703/MoneyPrinterTurbo/assets/4928832/271f2fae-8283-44a0-8aa0-0ed8f9a6fa87 + + + + + + + + + + + + + +
▶️《生命的意义是什么》▶️《为什么要运动》
## 安装部署 📥 +- 尽量不要使用 **中文路径**,避免出现一些无法预料的问题 +- 请确保你的 **网络** 是正常的,VPN需要打开`全局流量`模式 + +#### ① 克隆代码 + +```shell +git clone https://github.com/harry0703/MoneyPrinterTurbo.git +``` + +#### ② 修改配置文件 + +- 将 `config.example.toml` 文件复制一份,命名为 `config.toml` +- 按照 `config.toml` 文件中的说明,配置好 `pexels_api_keys` 和 `llm_provider`,并根据 llm_provider 对应的服务商,配置相关的 + API Key + +#### ③ 配置大模型(LLM) + +- 如果要使用 `GPT-4.0` 或 `GPT-3.5`,需要有 `OpenAI` 的 `API Key`,如果没有,可以将 `llm_provider` 设置为 `g4f` ( + 一个免费使用GPT的开源库 https://github.com/xtekky/gpt4free ,但是该免费的服务,稳定性较差,有时候可以用,有时候用不了) +- 或者可以使用到 [月之暗面](https://platform.moonshot.cn/console/api-keys) 申请。注册就送 + 15元体验金,可以对话1500次左右。然后设置 `llm_provider="moonshot"` 和 `moonshot_api_key` +- 也可以使用 通义千问,具体请看配置文件里面的注释说明 + +### Docker部署 🐳 + +#### ① 启动Docker + +如果未安装 Docker,请先安装 https://www.docker.com/products/docker-desktop/ + +如果是Windows系统,请参考微软的文档: +1. https://learn.microsoft.com/zh-cn/windows/wsl/install +2. https://learn.microsoft.com/zh-cn/windows/wsl/tutorials/wsl-containers + +```shell +cd MoneyPrinterTurbo +docker-compose up +``` + +#### ② 访问Web界面 + +打开浏览器,访问 http://0.0.0.0:8501 + +#### ③ 访问API文档 + +打开浏览器,访问 http://0.0.0.0:8080/docs 或者 http://0.0.0.0:8080/redoc + +### 手动部署 📦 + +> 视频教程 + +- 完整的使用演示:https://v.douyin.com/iFhnwsKY/ +- 如何在Windows上部署:https://v.douyin.com/iFyjoW3M + +#### ① 创建虚拟环境 + 建议使用 [conda](https://conda.io/projects/conda/en/latest/user-guide/install/index.html) 创建 python 虚拟环境 ```shell @@ -75,91 +168,58 @@ conda activate MoneyPrinterTurbo pip install -r requirements.txt ``` -## 快速使用 🚀 +#### ② 安装好 ImageMagick -### 视频教程 - -- 完整的使用演示:https://v.douyin.com/iFhnwsKY/ -- 如何在Windows上部署:https://v.douyin.com/iFyjoW3M - -### 前提 - -- 尽量不要使用 **中文路径**,避免出现一些无法预料的问题 -- 请确保你的 **网络** 是正常的,即可以正常访问境外网站 - -#### ① 安装好 ImageMagick - -##### Windows: +###### Windows: - 下载 https://imagemagick.org/archive/binaries/ImageMagick-7.1.1-29-Q16-x64-static.exe - 安装下载好的 ImageMagick,注意不要修改安装路径 +- 修改 `配置文件 config.toml` 中的 `imagemagick_path` 为你的实际安装路径(如果安装的时候没有修改路径,直接取消注释即可) -##### MacOS: +###### MacOS: ```shell brew install imagemagick ```` -##### Ubuntu +###### Ubuntu ```shell sudo apt-get install imagemagick ``` -##### CentOS +###### CentOS ```shell sudo yum install ImageMagick ``` -#### ② 修改配置文件 - -- 将 `config.example.toml` 文件复制一份,命名为 `config.toml` -- 按照 `config.toml` 文件中的说明,配置好 `pexels_api_keys` 和 `llm_provider`,并根据 llm_provider 对应的服务商,配置相关的 - API Key -- 如果是`Windows`系统,`imagemagick_path` 为你的实际安装路径(如果安装的时候没有修改路径,直接取消注释即可) - -#### ③ 配置大模型(LLM) - -- 如果要使用 `GPT-4.0` 或 `GPT-3.5`,需要有 `OpenAI` 的 `API Key`,如果没有,可以将 `llm_provider` 设置为 `g4f` ( - 一个免费使用GPT的开源库 https://github.com/xtekky/gpt4free) -- 或者可以使用到 [月之暗面](https://platform.moonshot.cn/console/api-keys) 申请。注册就送 - 15元体验金,可以对话1500次左右。然后设置 `llm_provider="moonshot"` 和 `moonshot_api_key` - 。感谢 [@jerryblues](https://github.com/harry0703/MoneyPrinterTurbo/issues/8) 的建议 - -### 启动Web界面 🌐 +#### ③ 启动Web界面 🌐 注意需要到 MoneyPrinterTurbo 项目 `根目录` 下执行以下命令 -#### Windows +###### Windows ```bat conda activate MoneyPrinterTurbo webui.bat ``` -#### MacOS or Linux +###### MacOS or Linux ```shell conda activate MoneyPrinterTurbo sh webui.sh ``` - 启动后,会自动打开浏览器 -效果如下图: -![](docs/webui.jpg) - -### 启动API服务 🚀 +#### ④ 启动API服务 🚀 ```shell python main.py ``` -启动后,可以查看 `API文档` http://127.0.0.1:8080/docs 直接在线调试接口,快速体验。 - -效果如下图: -![](docs/api.jpg) +启动后,可以查看 `API文档` http://127.0.0.1:8080/docs 或者 http://127.0.0.1:8080/redoc 直接在线调试接口,快速体验。 ## 语音合成 🗣 @@ -170,13 +230,15 @@ python main.py 当前支持2种字幕生成方式: - edge: 生成速度更快,性能更好,对电脑配置没有要求,但是质量可能不稳定 -- whisper: 生成速度较慢,性能较差,对电脑配置有一定要求,但是质量更可靠 +- whisper: 生成速度较慢,性能较差,对电脑配置有一定要求,但是质量更可靠。 可以修改 `config.toml` 配置文件中的 `subtitle_provider` 进行切换 建议使用 `edge` 模式,如果生成的字幕质量不好,再切换到 `whisper` 模式 -> 如果留空,表示不生成字幕。 +> 注意: +1. whisper 模式下需要到 HuggingFace 下载一个模型文件,大约 3GB 左右,请确保网络通畅 +2. 如果留空,表示不生成字幕。 ## 背景音乐 🎵 @@ -189,6 +251,12 @@ python main.py ## 常见问题 🤔 +### ❓AttributeError: 'str' object has no attribute 'choices'` + +这个问题是由于 OpenAI 或者其他 LLM,没有返回正确的回复导致的。 + +大概率是网络原因, 使用 **VPN**,或者设置 `openai_base_url` 为你的代理 ,应该就可以解决了。 + ### ❓RuntimeError: No ffmpeg exe could be found 通常情况下,ffmpeg 会被自动下载,并且会被自动检测到。 @@ -239,6 +307,55 @@ if you are in China, please use a VPN. 感谢 [@wangwenqiao666](https://github.com/wangwenqiao666)的研究探索 +### ❓ImageMagick的安全策略阻止了与临时文件@/tmp/tmpur5hyyto.txt相关的操作 + +[issue 92](https://github.com/harry0703/MoneyPrinterTurbo/issues/92) + +可以在ImageMagick的配置文件policy.xml中找到这些策略。 +这个文件通常位于 /etc/ImageMagick-`X`/ 或 ImageMagick 安装目录的类似位置。 +修改包含`pattern="@"`的条目,将`rights="none"`更改为`rights="read|write"`以允许对文件的读写操作。 + +感谢 [@chenhengzh](https://github.com/chenhengzh)的研究探索 + +### ❓OSError: [Errno 24] Too many open files + +[issue 100](https://github.com/harry0703/MoneyPrinterTurbo/issues/100) + +这个问题是由于系统打开文件数限制导致的,可以通过修改系统的文件打开数限制来解决。 + +查看当前限制 + +```shell +ulimit -n +``` + +如果过低,可以调高一些,比如 + +```shell +ulimit -n 10240 +``` + +### ❓AttributeError: module 'PIL.Image' has no attribute 'ANTIALIAS' + +[issue 101](https://github.com/harry0703/MoneyPrinterTurbo/issues/101), +[issue 83](https://github.com/harry0703/MoneyPrinterTurbo/issues/83), +[issue 70](https://github.com/harry0703/MoneyPrinterTurbo/issues/70) + +先看下当前的 Pillow 版本是多少 + +```shell +pip list |grep Pillow +``` + +如果是 10.x 的版本,可以尝试下降级看看,有用户反馈降级后正常 + +```shell +pip uninstall Pillow +pip install Pillow==9.5.0 +# 或者降级到 8.4.0 +pip install Pillow==8.4.0 +``` + ## 反馈建议 📢 - 可以提交 [issue](https://github.com/harry0703/MoneyPrinterTurbo/issues) @@ -261,3 +378,7 @@ if you are in China, please use a VPN. 点击查看 [`LICENSE`](LICENSE) 文件 + +## Star History + +[![Star History Chart](https://api.star-history.com/svg?repos=harry0703/MoneyPrinterTurbo&type=Date)](https://star-history.com/#harry0703/MoneyPrinterTurbo&Date) \ No newline at end of file diff --git a/app/asgi.py b/app/asgi.py index 8a00b0d..440e4c0 100644 --- a/app/asgi.py +++ b/app/asgi.py @@ -46,6 +46,10 @@ def get_application() -> FastAPI: app = get_application() + +task_dir = utils.task_dir() +app.mount("/tasks", StaticFiles(directory=task_dir, html=True, follow_symlink=True), name="") + public_dir = utils.public_dir() app.mount("/", StaticFiles(directory=public_dir, html=True), name="") diff --git a/app/config/config.py b/app/config/config.py index f4866ed..0c3120b 100644 --- a/app/config/config.py +++ b/app/config/config.py @@ -5,6 +5,14 @@ from loguru import logger root_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.realpath(__file__)))) config_file = f"{root_dir}/config.toml" +if not os.path.isfile(config_file): + example_file = f"{root_dir}/config.example.toml" + if os.path.isfile(example_file): + import shutil + + shutil.copyfile(example_file, config_file) + logger.info(f"copy config.example.toml to config.toml") + logger.info(f"load config from file: {config_file}") with open(config_file, mode="rb") as fp: @@ -20,8 +28,9 @@ log_level = _cfg.get("log_level", "DEBUG") listen_host = _cfg.get("listen_host", "0.0.0.0") listen_port = _cfg.get("listen_port", 8080) project_name = _cfg.get("project_name", "MoneyPrinterTurbo") -project_description = _cfg.get("project_description", "MoneyPrinterTurbo\n by 抖音-网旭哈瑞.AI") -project_version = _cfg.get("project_version", "1.0.0") +project_description = _cfg.get("project_description", + "https://github.com/harry0703/MoneyPrinterTurbo") +project_version = _cfg.get("project_version", "1.0.1") reload_debug = False imagemagick_path = app.get("imagemagick_path", "") diff --git a/app/controllers/v1/video.py b/app/controllers/v1/video.py index 0f450ee..7823509 100644 --- a/app/controllers/v1/video.py +++ b/app/controllers/v1/video.py @@ -1,13 +1,13 @@ -from os import path - -from fastapi import Request, Depends, Path +from fastapi import Request, Depends, Path, BackgroundTasks from loguru import logger +from app.config import config from app.controllers import base from app.controllers.v1.base import new_router from app.models.exception import HttpException from app.models.schema import TaskVideoRequest, TaskQueryResponse, TaskResponse, TaskQueryRequest from app.services import task as tm +from app.services import state as sm from app.utils import utils # 认证依赖项 @@ -15,30 +15,43 @@ from app.utils import utils router = new_router() -@router.post("/videos", response_model=TaskResponse, summary="使用主题来生成短视频") -def create_video(request: Request, body: TaskVideoRequest): +@router.post("/videos", response_model=TaskResponse, summary="Generate a short video") +def create_video(background_tasks: BackgroundTasks, request: Request, body: TaskVideoRequest): task_id = utils.get_uuid() request_id = base.get_task_id(request) try: task = { "task_id": task_id, "request_id": request_id, + "params": body.dict(), } - body_dict = body.dict() - task.update(body_dict) - result = tm.start(task_id=task_id, params=body) - task["result"] = result + sm.update_task(task_id) + background_tasks.add_task(tm.start, task_id=task_id, params=body) logger.success(f"video created: {utils.to_json(task)}") return utils.get_response(200, task) except ValueError as e: raise HttpException(task_id=task_id, status_code=400, message=f"{request_id}: {str(e)}") -@router.get("/tasks/{task_id}", response_model=TaskQueryResponse, summary="查询任务状态") -def get_task(request: Request, task_id: str = Path(..., description="任务ID"), - query: TaskQueryRequest = Depends()): +@router.get("/tasks/{task_id}", response_model=TaskQueryResponse, summary="Query task status") +def get_task(request: Request, task_id: str = Path(..., description="Task ID"), + query: TaskQueryRequest = Depends()): + endpoint = config.app.get("endpoint", "") + if not endpoint: + endpoint = str(request.base_url) + endpoint = endpoint.rstrip("/") + request_id = base.get_task_id(request) - data = query.dict() - data["task_id"] = task_id - raise HttpException(task_id=task_id, status_code=404, - message=f"{request_id}: task not found", data=data) + task = sm.get_task(task_id) + if task: + if "videos" in task: + videos = task["videos"] + task_dir = utils.task_dir() + urls = [] + for v in videos: + uri_path = v.replace(task_dir, "tasks") + urls.append(f"{endpoint}/{uri_path}") + task["videos"] = urls + return utils.get_response(200, task) + + raise HttpException(task_id=task_id, status_code=404, message=f"{request_id}: task not found") diff --git a/app/models/const.py b/app/models/const.py index 0ea3b76..2a56585 100644 --- a/app/models/const.py +++ b/app/models/const.py @@ -1,4 +1,8 @@ -punctuations = [ - "?", ",", ".", "、", ";", ":", - "?", ",", "。", "、", ";", ":", +PUNCTUATIONS = [ + "?", ",", ".", "、", ";", ":", "!", "…", + "?", ",", "。", "、", ";", ":", "!", "...", ] + +TASK_STATE_FAILED = -1 +TASK_STATE_COMPLETE = 1 +TASK_STATE_PROCESSING = 4 diff --git a/app/models/schema.py b/app/models/schema.py index 730006d..b686000 100644 --- a/app/models/schema.py +++ b/app/models/schema.py @@ -34,43 +34,43 @@ class MaterialInfo: duration: int = 0 -VoiceNames = [ - # zh-CN - "female-zh-CN-XiaoxiaoNeural", - "female-zh-CN-XiaoyiNeural", - "female-zh-CN-liaoning-XiaobeiNeural", - "female-zh-CN-shaanxi-XiaoniNeural", - - "male-zh-CN-YunjianNeural", - "male-zh-CN-YunxiNeural", - "male-zh-CN-YunxiaNeural", - "male-zh-CN-YunyangNeural", - - # "female-zh-HK-HiuGaaiNeural", - # "female-zh-HK-HiuMaanNeural", - # "male-zh-HK-WanLungNeural", - # - # "female-zh-TW-HsiaoChenNeural", - # "female-zh-TW-HsiaoYuNeural", - # "male-zh-TW-YunJheNeural", - - # en-US - - "female-en-US-AnaNeural", - "female-en-US-AriaNeural", - "female-en-US-AvaNeural", - "female-en-US-EmmaNeural", - "female-en-US-JennyNeural", - "female-en-US-MichelleNeural", - - "male-en-US-AndrewNeural", - "male-en-US-BrianNeural", - "male-en-US-ChristopherNeural", - "male-en-US-EricNeural", - "male-en-US-GuyNeural", - "male-en-US-RogerNeural", - "male-en-US-SteffanNeural", -] +# VoiceNames = [ +# # zh-CN +# "female-zh-CN-XiaoxiaoNeural", +# "female-zh-CN-XiaoyiNeural", +# "female-zh-CN-liaoning-XiaobeiNeural", +# "female-zh-CN-shaanxi-XiaoniNeural", +# +# "male-zh-CN-YunjianNeural", +# "male-zh-CN-YunxiNeural", +# "male-zh-CN-YunxiaNeural", +# "male-zh-CN-YunyangNeural", +# +# # "female-zh-HK-HiuGaaiNeural", +# # "female-zh-HK-HiuMaanNeural", +# # "male-zh-HK-WanLungNeural", +# # +# # "female-zh-TW-HsiaoChenNeural", +# # "female-zh-TW-HsiaoYuNeural", +# # "male-zh-TW-YunJheNeural", +# +# # en-US +# +# "female-en-US-AnaNeural", +# "female-en-US-AriaNeural", +# "female-en-US-AvaNeural", +# "female-en-US-EmmaNeural", +# "female-en-US-JennyNeural", +# "female-en-US-MichelleNeural", +# +# "male-en-US-AndrewNeural", +# "male-en-US-BrianNeural", +# "male-en-US-ChristopherNeural", +# "male-en-US-EricNeural", +# "male-en-US-GuyNeural", +# "male-en-US-RogerNeural", +# "male-en-US-SteffanNeural", +# ] class VideoParams: @@ -97,7 +97,7 @@ class VideoParams: video_language: Optional[str] = "" # auto detect - voice_name: Optional[str] = VoiceNames[0] + voice_name: Optional[str] = "" bgm_type: Optional[str] = "random" bgm_file: Optional[str] = "" bgm_volume: Optional[float] = 0.2 @@ -136,10 +136,33 @@ class TaskQueryRequest(BaseModel): class TaskResponse(BaseResponse): class TaskResponseData(BaseModel): task_id: str - task_type: str = "" data: TaskResponseData + class Config: + json_schema_extra = { + "example": { + "status": 200, + "message": "success", + "data": { + "task_id": "6c85c8cc-a77a-42b9-bc30-947815aa0558" + } + }, + } + class TaskQueryResponse(BaseResponse): - pass + class Config: + json_schema_extra = { + "example": { + "status": 200, + "message": "success", + "data": { + "state": 1, + "progress": 100, + "videos": [ + "http://127.0.0.1:8080/tasks/6c85c8cc-a77a-42b9-bc30-947815aa0558/final-1.mp4" + ] + } + }, + } diff --git a/app/services/llm.py b/app/services/llm.py index ebcf127..84ada77 100644 --- a/app/services/llm.py +++ b/app/services/llm.py @@ -5,9 +5,9 @@ from typing import List from loguru import logger from openai import OpenAI from openai import AzureOpenAI +import google.generativeai as genai from app.config import config - def _generate_response(prompt: str) -> str: content = "" llm_provider = config.app.get("llm_provider", "openai") @@ -42,6 +42,10 @@ def _generate_response(prompt: str) -> str: model_name = config.app.get("azure_model_name") base_url = config.app.get("azure_base_url", "") api_version = config.app.get("azure_api_version", "2024-02-15-preview") + elif llm_provider == "gemini": + api_key = config.app.get("gemini_api_key") + model_name = config.app.get("gemini_model_name") + base_url = "***" elif llm_provider == "qwen": api_key = config.app.get("qwen_api_key") model_name = config.app.get("qwen_model_name") @@ -66,6 +70,44 @@ def _generate_response(prompt: str) -> str: content = response["output"]["text"] return content.replace("\n", "") + if llm_provider == "gemini": + genai.configure(api_key=api_key) + + generation_config = { + "temperature": 0.5, + "top_p": 1, + "top_k": 1, + "max_output_tokens": 2048, + } + + safety_settings = [ + { + "category": "HARM_CATEGORY_HARASSMENT", + "threshold": "BLOCK_ONLY_HIGH" + }, + { + "category": "HARM_CATEGORY_HATE_SPEECH", + "threshold": "BLOCK_ONLY_HIGH" + }, + { + "category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", + "threshold": "BLOCK_ONLY_HIGH" + }, + { + "category": "HARM_CATEGORY_DANGEROUS_CONTENT", + "threshold": "BLOCK_ONLY_HIGH" + }, + ] + + model = genai.GenerativeModel(model_name=model_name, + generation_config=generation_config, + safety_settings=safety_settings) + + convo = model.start_chat(history=[]) + + convo.send_message(prompt) + return convo.last.text + if llm_provider == "azure": client = AzureOpenAI( api_key=api_key, @@ -169,6 +211,8 @@ Generate {amount} search terms for stock videos, depending on the subject of a v ### Video Script {video_script} + +Please note that you must use English for generating video search terms; Chinese is not accepted. """.strip() logger.info(f"subject: {video_subject}") diff --git a/app/services/material.py b/app/services/material.py index f9c108e..29f61eb 100644 --- a/app/services/material.py +++ b/app/services/material.py @@ -17,6 +17,10 @@ if not pexels_api_keys: def round_robin_api_key(): + # if only one key is provided, return it + if isinstance(pexels_api_keys, str): + return pexels_api_keys + global requested_count requested_count += 1 return pexels_api_keys[requested_count % len(pexels_api_keys)] diff --git a/app/services/state.py b/app/services/state.py new file mode 100644 index 0000000..606a2c1 --- /dev/null +++ b/app/services/state.py @@ -0,0 +1,35 @@ +# State Management +# This module is responsible for managing the state of the application. +import math + +# 如果你部署在分布式环境中,你可能需要一个中心化的状态管理服务,比如 Redis 或者数据库。 +# 如果你的应用程序是单机的,你可以使用内存来存储状态。 + +# If you are deploying in a distributed environment, you might need a centralized state management service like Redis or a database. +# If your application is single-node, you can use memory to store the state. + +from app.models import const +from app.utils import utils + +_tasks = {} + + +def update_task(task_id: str, state: int = const.TASK_STATE_PROCESSING, progress: int = 0, **kwargs): + """ + Set the state of the task. + """ + progress = int(progress) + if progress > 100: + progress = 100 + + _tasks[task_id] = { + "state": state, + "progress": progress, + **kwargs, + } + +def get_task(task_id: str): + """ + Get the state of the task. + """ + return _tasks.get(task_id, None) diff --git a/app/services/subtitle.py b/app/services/subtitle.py index df929f4..5ea8028 100644 --- a/app/services/subtitle.py +++ b/app/services/subtitle.py @@ -20,8 +20,7 @@ def create(audio_file, subtitle_file: str = ""): logger.info(f"loading model: {model_size}, device: {device}, compute_type: {compute_type}") model = WhisperModel(model_size_or_path=model_size, device=device, - compute_type=compute_type, - local_files_only=True) + compute_type=compute_type) logger.info(f"start, output file: {subtitle_file}") if not subtitle_file: @@ -160,6 +159,7 @@ if __name__ == "__main__": task_id = "c12fd1e6-4b0a-4d65-a075-c87abe35a072" task_dir = utils.task_dir(task_id) subtitle_file = f"{task_dir}/subtitle.srt" + audio_file = f"{task_dir}/audio.mp3" subtitles = file_to_subtitles(subtitle_file) print(subtitles) @@ -171,3 +171,6 @@ if __name__ == "__main__": script = s.get("script") correct(subtitle_file, script) + + subtitle_file = f"{task_dir}/subtitle-test.srt" + create(audio_file, subtitle_file) diff --git a/app/services/task.py b/app/services/task.py index e454a26..3adf44d 100644 --- a/app/services/task.py +++ b/app/services/task.py @@ -6,24 +6,13 @@ from os import path from loguru import logger from app.config import config -from app.models.schema import VideoParams, VoiceNames, VideoConcatMode +from app.models import const +from app.models.schema import VideoParams, VideoConcatMode from app.services import llm, material, voice, video, subtitle +from app.services import state as sm from app.utils import utils -def _parse_voice(name: str): - # "female-zh-CN-XiaoxiaoNeural", - # remove first part split by "-" - if name not in VoiceNames: - name = VoiceNames[0] - - parts = name.split("-") - _lang = f"{parts[1]}-{parts[2]}" - _voice = f"{_lang}-{parts[3]}" - - return _voice, _lang - - def start(task_id, params: VideoParams): """ { @@ -39,8 +28,10 @@ def start(task_id, params: VideoParams): } """ logger.info(f"start task: {task_id}") + sm.update_task(task_id, state=const.TASK_STATE_PROCESSING, progress=5) + video_subject = params.video_subject - voice_name, language = _parse_voice(params.voice_name) + voice_name = voice.parse_voice_name(params.voice_name) paragraph_number = params.paragraph_number n_threads = params.n_threads max_clip_duration = params.video_clip_duration @@ -53,6 +44,8 @@ def start(task_id, params: VideoParams): else: logger.debug(f"video script: \n{video_script}") + sm.update_task(task_id, state=const.TASK_STATE_PROCESSING, progress=10) + logger.info("\n\n## generating video terms") video_terms = params.video_terms if not video_terms: @@ -70,16 +63,20 @@ def start(task_id, params: VideoParams): script_file = path.join(utils.task_dir(task_id), f"script.json") script_data = { "script": video_script, - "search_terms": video_terms + "search_terms": video_terms, + "params": params, } with open(script_file, "w", encoding="utf-8") as f: f.write(utils.to_json(script_data)) + sm.update_task(task_id, state=const.TASK_STATE_PROCESSING, progress=20) + logger.info("\n\n## generating audio") audio_file = path.join(utils.task_dir(task_id), f"audio.mp3") sub_maker = voice.tts(text=video_script, voice_name=voice_name, voice_file=audio_file) if sub_maker is None: + sm.update_task(task_id, state=const.TASK_STATE_FAILED) logger.error( "failed to generate audio, maybe the network is not available. if you are in China, please use a VPN.") return @@ -87,6 +84,8 @@ def start(task_id, params: VideoParams): audio_duration = voice.get_audio_duration(sub_maker) audio_duration = math.ceil(audio_duration) + sm.update_task(task_id, state=const.TASK_STATE_PROCESSING, progress=30) + subtitle_path = "" if params.subtitle_enabled: subtitle_path = path.join(utils.task_dir(task_id), f"subtitle.srt") @@ -98,11 +97,6 @@ def start(task_id, params: VideoParams): if not os.path.exists(subtitle_path): subtitle_fallback = True logger.warning("subtitle file not found, fallback to whisper") - else: - subtitle_lines = subtitle.file_to_subtitles(subtitle_path) - if not subtitle_lines: - logger.warning(f"subtitle file is invalid, fallback to whisper : {subtitle_path}") - subtitle_fallback = True if subtitle_provider == "whisper" or subtitle_fallback: subtitle.create(audio_file=audio_file, subtitle_file=subtitle_path) @@ -114,6 +108,8 @@ def start(task_id, params: VideoParams): logger.warning(f"subtitle file is invalid: {subtitle_path}") subtitle_path = "" + sm.update_task(task_id, state=const.TASK_STATE_PROCESSING, progress=40) + logger.info("\n\n## downloading videos") downloaded_videos = material.download_videos(task_id=task_id, search_terms=video_terms, @@ -123,15 +119,19 @@ def start(task_id, params: VideoParams): max_clip_duration=max_clip_duration, ) if not downloaded_videos: + sm.update_task(task_id, state=const.TASK_STATE_FAILED) logger.error( "failed to download videos, maybe the network is not available. if you are in China, please use a VPN.") return + sm.update_task(task_id, state=const.TASK_STATE_PROCESSING, progress=50) + final_video_paths = [] video_concat_mode = params.video_concat_mode if params.video_count > 1: video_concat_mode = VideoConcatMode.random + _progress = 50 for i in range(params.video_count): index = i + 1 combined_video_path = path.join(utils.task_dir(task_id), f"combined-{index}.mp4") @@ -144,6 +144,9 @@ def start(task_id, params: VideoParams): max_clip_duration=max_clip_duration, threads=n_threads) + _progress += 50 / params.video_count / 2 + sm.update_task(task_id, progress=_progress) + final_video_path = path.join(utils.task_dir(task_id), f"final-{index}.mp4") logger.info(f"\n\n## generating video: {index} => {final_video_path}") @@ -154,10 +157,16 @@ def start(task_id, params: VideoParams): output_file=final_video_path, params=params, ) + + _progress += 50 / params.video_count / 2 + sm.update_task(task_id, progress=_progress) + final_video_paths.append(final_video_path) logger.success(f"task {task_id} finished, generated {len(final_video_paths)} videos.") - return { + kwargs = { "videos": final_video_paths, } + sm.update_task(task_id, state=const.TASK_STATE_COMPLETE, progress=100, **kwargs) + return kwargs diff --git a/app/services/video.py b/app/services/video.py index 2f8fdbd..1ebc9ae 100644 --- a/app/services/video.py +++ b/app/services/video.py @@ -64,24 +64,34 @@ def combine_videos(combined_video_path: str, clip = clip.set_fps(30) # Not all videos are same size, so we need to resize them - # logger.info(f"{video_path}: size is {clip.w} x {clip.h}, expected {video_width} x {video_height}") - if clip.w != video_width or clip.h != video_height: - if round((clip.w / clip.h), 4) < 0.5625: - clip = crop(clip, - width=clip.w, - height=round(clip.w / 0.5625), - x_center=clip.w / 2, - y_center=clip.h / 2 - ) + clip_w, clip_h = clip.size + if clip_w != video_width or clip_h != video_height: + clip_ratio = clip.w / clip.h + video_ratio = video_width / video_height + + if clip_ratio == video_ratio: + # 等比例缩放 + clip = clip.resize((video_width, video_height)) else: - clip = crop(clip, - width=round(0.5625 * clip.h), - height=clip.h, - x_center=clip.w / 2, - y_center=clip.h / 2 - ) - logger.info(f"resizing video to {video_width} x {video_height}") - clip = clip.resize((video_width, video_height)) + # 等比缩放视频 + if clip_ratio > video_ratio: + # 按照目标宽度等比缩放 + scale_factor = video_width / clip_w + else: + # 按照目标高度等比缩放 + scale_factor = video_height / clip_h + + new_width = int(clip_w * scale_factor) + new_height = int(clip_h * scale_factor) + clip_resized = clip.resize(newsize=(new_width, new_height)) + + background = ColorClip(size=(video_width, video_height), color=(0, 0, 0)) + clip = CompositeVideoClip([ + background.set_duration(clip.duration), + clip_resized.set_position("center") + ]) + + logger.info(f"resizing video to {video_width} x {video_height}, clip size: {clip_w} x {clip_h}") if clip.duration > max_clip_duration: clip = clip.subclip(0, max_clip_duration) @@ -92,7 +102,8 @@ def combine_videos(combined_video_path: str, final_clip = concatenate_videoclips(clips) final_clip = final_clip.set_fps(30) logger.info(f"writing") - final_clip.write_videofile(combined_video_path, threads=threads) + # https://github.com/harry0703/MoneyPrinterTurbo/issues/111#issuecomment-2032354030 + final_clip.write_videofile(combined_video_path, threads=threads, logger=None) logger.success(f"completed") return combined_video_path @@ -222,7 +233,7 @@ def generate_video(video_path: str, temp_output_file = f"{output_file}.temp.mp4" logger.info(f"writing to temp file: {temp_output_file}") - result.write_videofile(temp_output_file, threads=params.n_threads or 2) + result.write_videofile(temp_output_file, threads=params.n_threads or 2, logger=None) video_clip = VideoFileClip(temp_output_file) @@ -242,7 +253,7 @@ def generate_video(video_path: str, video_clip = video_clip.set_duration(original_duration) logger.info(f"encoding audio codec to aac") - video_clip.write_videofile(output_file, audio_codec="aac", threads=params.n_threads or 2) + video_clip.write_videofile(output_file, audio_codec="aac", threads=params.n_threads or 2, logger=None) os.remove(temp_output_file) logger.success(f"completed") @@ -262,6 +273,20 @@ if __name__ == "__main__": audio_file = f"{task_dir}/audio.mp3" subtitle_file = f"{task_dir}/subtitle.srt" output_file = f"{task_dir}/final.mp4" + + video_paths = [] + for file in os.listdir(utils.storage_dir("test")): + if file.endswith(".mp4"): + video_paths.append(os.path.join(task_dir, file)) + + combine_videos(combined_video_path=video_file, + audio_file=audio_file, + video_paths=video_paths, + video_aspect=VideoAspect.portrait, + video_concat_mode=VideoConcatMode.random, + max_clip_duration=5, + threads=2) + cfg = VideoParams() cfg.video_aspect = VideoAspect.portrait cfg.font_name = "STHeitiMedium.ttc" diff --git a/app/services/voice.py b/app/services/voice.py index cbaca4d..220a4e5 100644 --- a/app/services/voice.py +++ b/app/services/voice.py @@ -1,33 +1,1022 @@ import asyncio -from concurrent.futures import ThreadPoolExecutor +import os +import re from xml.sax.saxutils import unescape from edge_tts.submaker import mktimestamp from loguru import logger from edge_tts import submaker, SubMaker import edge_tts +from moviepy.video.tools import subtitles + from app.utils import utils -def tts(text: str, voice_name: str, voice_file: str) -> [SubMaker, None]: - logger.info(f"start, voice name: {voice_name}") - try: - async def _do() -> SubMaker: - communicate = edge_tts.Communicate(text, voice_name) - sub_maker = edge_tts.SubMaker() - with open(voice_file, "wb") as file: - async for chunk in communicate.stream(): - if chunk["type"] == "audio": - file.write(chunk["data"]) - elif chunk["type"] == "WordBoundary": - sub_maker.create_sub((chunk["offset"], chunk["duration"]), chunk["text"]) - return sub_maker +def get_all_voices(filter_locals=None) -> list[str]: + if filter_locals is None: + filter_locals = ["zh-CN", "en-US", "zh-HK", "zh-TW"] + voices_str = """ +Name: af-ZA-AdriNeural +Gender: Female - sub_maker = asyncio.run(_do()) - logger.info(f"completed, output file: {voice_file}") - return sub_maker - except Exception as e: - logger.error(f"failed, error: {str(e)}") - return None +Name: af-ZA-WillemNeural +Gender: Male + +Name: am-ET-AmehaNeural +Gender: Male + +Name: am-ET-MekdesNeural +Gender: Female + +Name: ar-AE-FatimaNeural +Gender: Female + +Name: ar-AE-HamdanNeural +Gender: Male + +Name: ar-BH-AliNeural +Gender: Male + +Name: ar-BH-LailaNeural +Gender: Female + +Name: ar-DZ-AminaNeural +Gender: Female + +Name: ar-DZ-IsmaelNeural +Gender: Male + +Name: ar-EG-SalmaNeural +Gender: Female + +Name: ar-EG-ShakirNeural +Gender: Male + +Name: ar-IQ-BasselNeural +Gender: Male + +Name: ar-IQ-RanaNeural +Gender: Female + +Name: ar-JO-SanaNeural +Gender: Female + +Name: ar-JO-TaimNeural +Gender: Male + +Name: ar-KW-FahedNeural +Gender: Male + +Name: ar-KW-NouraNeural +Gender: Female + +Name: ar-LB-LaylaNeural +Gender: Female + +Name: ar-LB-RamiNeural +Gender: Male + +Name: ar-LY-ImanNeural +Gender: Female + +Name: ar-LY-OmarNeural +Gender: Male + +Name: ar-MA-JamalNeural +Gender: Male + +Name: ar-MA-MounaNeural +Gender: Female + +Name: ar-OM-AbdullahNeural +Gender: Male + +Name: ar-OM-AyshaNeural +Gender: Female + +Name: ar-QA-AmalNeural +Gender: Female + +Name: ar-QA-MoazNeural +Gender: Male + +Name: ar-SA-HamedNeural +Gender: Male + +Name: ar-SA-ZariyahNeural +Gender: Female + +Name: ar-SY-AmanyNeural +Gender: Female + +Name: ar-SY-LaithNeural +Gender: Male + +Name: ar-TN-HediNeural +Gender: Male + +Name: ar-TN-ReemNeural +Gender: Female + +Name: ar-YE-MaryamNeural +Gender: Female + +Name: ar-YE-SalehNeural +Gender: Male + +Name: az-AZ-BabekNeural +Gender: Male + +Name: az-AZ-BanuNeural +Gender: Female + +Name: bg-BG-BorislavNeural +Gender: Male + +Name: bg-BG-KalinaNeural +Gender: Female + +Name: bn-BD-NabanitaNeural +Gender: Female + +Name: bn-BD-PradeepNeural +Gender: Male + +Name: bn-IN-BashkarNeural +Gender: Male + +Name: bn-IN-TanishaaNeural +Gender: Female + +Name: bs-BA-GoranNeural +Gender: Male + +Name: bs-BA-VesnaNeural +Gender: Female + +Name: ca-ES-EnricNeural +Gender: Male + +Name: ca-ES-JoanaNeural +Gender: Female + +Name: cs-CZ-AntoninNeural +Gender: Male + +Name: cs-CZ-VlastaNeural +Gender: Female + +Name: cy-GB-AledNeural +Gender: Male + +Name: cy-GB-NiaNeural +Gender: Female + +Name: da-DK-ChristelNeural +Gender: Female + +Name: da-DK-JeppeNeural +Gender: Male + +Name: de-AT-IngridNeural +Gender: Female + +Name: de-AT-JonasNeural +Gender: Male + +Name: de-CH-JanNeural +Gender: Male + +Name: de-CH-LeniNeural +Gender: Female + +Name: de-DE-AmalaNeural +Gender: Female + +Name: de-DE-ConradNeural +Gender: Male + +Name: de-DE-FlorianMultilingualNeural +Gender: Male + +Name: de-DE-KatjaNeural +Gender: Female + +Name: de-DE-KillianNeural +Gender: Male + +Name: de-DE-SeraphinaMultilingualNeural +Gender: Female + +Name: el-GR-AthinaNeural +Gender: Female + +Name: el-GR-NestorasNeural +Gender: Male + +Name: en-AU-NatashaNeural +Gender: Female + +Name: en-AU-WilliamNeural +Gender: Male + +Name: en-CA-ClaraNeural +Gender: Female + +Name: en-CA-LiamNeural +Gender: Male + +Name: en-GB-LibbyNeural +Gender: Female + +Name: en-GB-MaisieNeural +Gender: Female + +Name: en-GB-RyanNeural +Gender: Male + +Name: en-GB-SoniaNeural +Gender: Female + +Name: en-GB-ThomasNeural +Gender: Male + +Name: en-HK-SamNeural +Gender: Male + +Name: en-HK-YanNeural +Gender: Female + +Name: en-IE-ConnorNeural +Gender: Male + +Name: en-IE-EmilyNeural +Gender: Female + +Name: en-IN-NeerjaExpressiveNeural +Gender: Female + +Name: en-IN-NeerjaNeural +Gender: Female + +Name: en-IN-PrabhatNeural +Gender: Male + +Name: en-KE-AsiliaNeural +Gender: Female + +Name: en-KE-ChilembaNeural +Gender: Male + +Name: en-NG-AbeoNeural +Gender: Male + +Name: en-NG-EzinneNeural +Gender: Female + +Name: en-NZ-MitchellNeural +Gender: Male + +Name: en-NZ-MollyNeural +Gender: Female + +Name: en-PH-JamesNeural +Gender: Male + +Name: en-PH-RosaNeural +Gender: Female + +Name: en-SG-LunaNeural +Gender: Female + +Name: en-SG-WayneNeural +Gender: Male + +Name: en-TZ-ElimuNeural +Gender: Male + +Name: en-TZ-ImaniNeural +Gender: Female + +Name: en-US-AnaNeural +Gender: Female + +Name: en-US-AndrewNeural +Gender: Male + +Name: en-US-AriaNeural +Gender: Female + +Name: en-US-AvaNeural +Gender: Female + +Name: en-US-BrianNeural +Gender: Male + +Name: en-US-ChristopherNeural +Gender: Male + +Name: en-US-EmmaNeural +Gender: Female + +Name: en-US-EricNeural +Gender: Male + +Name: en-US-GuyNeural +Gender: Male + +Name: en-US-JennyNeural +Gender: Female + +Name: en-US-MichelleNeural +Gender: Female + +Name: en-US-RogerNeural +Gender: Male + +Name: en-US-SteffanNeural +Gender: Male + +Name: en-ZA-LeahNeural +Gender: Female + +Name: en-ZA-LukeNeural +Gender: Male + +Name: es-AR-ElenaNeural +Gender: Female + +Name: es-AR-TomasNeural +Gender: Male + +Name: es-BO-MarceloNeural +Gender: Male + +Name: es-BO-SofiaNeural +Gender: Female + +Name: es-CL-CatalinaNeural +Gender: Female + +Name: es-CL-LorenzoNeural +Gender: Male + +Name: es-CO-GonzaloNeural +Gender: Male + +Name: es-CO-SalomeNeural +Gender: Female + +Name: es-CR-JuanNeural +Gender: Male + +Name: es-CR-MariaNeural +Gender: Female + +Name: es-CU-BelkysNeural +Gender: Female + +Name: es-CU-ManuelNeural +Gender: Male + +Name: es-DO-EmilioNeural +Gender: Male + +Name: es-DO-RamonaNeural +Gender: Female + +Name: es-EC-AndreaNeural +Gender: Female + +Name: es-EC-LuisNeural +Gender: Male + +Name: es-ES-AlvaroNeural +Gender: Male + +Name: es-ES-ElviraNeural +Gender: Female + +Name: es-ES-XimenaNeural +Gender: Female + +Name: es-GQ-JavierNeural +Gender: Male + +Name: es-GQ-TeresaNeural +Gender: Female + +Name: es-GT-AndresNeural +Gender: Male + +Name: es-GT-MartaNeural +Gender: Female + +Name: es-HN-CarlosNeural +Gender: Male + +Name: es-HN-KarlaNeural +Gender: Female + +Name: es-MX-DaliaNeural +Gender: Female + +Name: es-MX-JorgeNeural +Gender: Male + +Name: es-NI-FedericoNeural +Gender: Male + +Name: es-NI-YolandaNeural +Gender: Female + +Name: es-PA-MargaritaNeural +Gender: Female + +Name: es-PA-RobertoNeural +Gender: Male + +Name: es-PE-AlexNeural +Gender: Male + +Name: es-PE-CamilaNeural +Gender: Female + +Name: es-PR-KarinaNeural +Gender: Female + +Name: es-PR-VictorNeural +Gender: Male + +Name: es-PY-MarioNeural +Gender: Male + +Name: es-PY-TaniaNeural +Gender: Female + +Name: es-SV-LorenaNeural +Gender: Female + +Name: es-SV-RodrigoNeural +Gender: Male + +Name: es-US-AlonsoNeural +Gender: Male + +Name: es-US-PalomaNeural +Gender: Female + +Name: es-UY-MateoNeural +Gender: Male + +Name: es-UY-ValentinaNeural +Gender: Female + +Name: es-VE-PaolaNeural +Gender: Female + +Name: es-VE-SebastianNeural +Gender: Male + +Name: et-EE-AnuNeural +Gender: Female + +Name: et-EE-KertNeural +Gender: Male + +Name: fa-IR-DilaraNeural +Gender: Female + +Name: fa-IR-FaridNeural +Gender: Male + +Name: fi-FI-HarriNeural +Gender: Male + +Name: fi-FI-NooraNeural +Gender: Female + +Name: fil-PH-AngeloNeural +Gender: Male + +Name: fil-PH-BlessicaNeural +Gender: Female + +Name: fr-BE-CharlineNeural +Gender: Female + +Name: fr-BE-GerardNeural +Gender: Male + +Name: fr-CA-AntoineNeural +Gender: Male + +Name: fr-CA-JeanNeural +Gender: Male + +Name: fr-CA-SylvieNeural +Gender: Female + +Name: fr-CA-ThierryNeural +Gender: Male + +Name: fr-CH-ArianeNeural +Gender: Female + +Name: fr-CH-FabriceNeural +Gender: Male + +Name: fr-FR-DeniseNeural +Gender: Female + +Name: fr-FR-EloiseNeural +Gender: Female + +Name: fr-FR-HenriNeural +Gender: Male + +Name: fr-FR-RemyMultilingualNeural +Gender: Male + +Name: fr-FR-VivienneMultilingualNeural +Gender: Female + +Name: ga-IE-ColmNeural +Gender: Male + +Name: ga-IE-OrlaNeural +Gender: Female + +Name: gl-ES-RoiNeural +Gender: Male + +Name: gl-ES-SabelaNeural +Gender: Female + +Name: gu-IN-DhwaniNeural +Gender: Female + +Name: gu-IN-NiranjanNeural +Gender: Male + +Name: he-IL-AvriNeural +Gender: Male + +Name: he-IL-HilaNeural +Gender: Female + +Name: hi-IN-MadhurNeural +Gender: Male + +Name: hi-IN-SwaraNeural +Gender: Female + +Name: hr-HR-GabrijelaNeural +Gender: Female + +Name: hr-HR-SreckoNeural +Gender: Male + +Name: hu-HU-NoemiNeural +Gender: Female + +Name: hu-HU-TamasNeural +Gender: Male + +Name: id-ID-ArdiNeural +Gender: Male + +Name: id-ID-GadisNeural +Gender: Female + +Name: is-IS-GudrunNeural +Gender: Female + +Name: is-IS-GunnarNeural +Gender: Male + +Name: it-IT-DiegoNeural +Gender: Male + +Name: it-IT-ElsaNeural +Gender: Female + +Name: it-IT-GiuseppeNeural +Gender: Male + +Name: it-IT-IsabellaNeural +Gender: Female + +Name: ja-JP-KeitaNeural +Gender: Male + +Name: ja-JP-NanamiNeural +Gender: Female + +Name: jv-ID-DimasNeural +Gender: Male + +Name: jv-ID-SitiNeural +Gender: Female + +Name: ka-GE-EkaNeural +Gender: Female + +Name: ka-GE-GiorgiNeural +Gender: Male + +Name: kk-KZ-AigulNeural +Gender: Female + +Name: kk-KZ-DauletNeural +Gender: Male + +Name: km-KH-PisethNeural +Gender: Male + +Name: km-KH-SreymomNeural +Gender: Female + +Name: kn-IN-GaganNeural +Gender: Male + +Name: kn-IN-SapnaNeural +Gender: Female + +Name: ko-KR-HyunsuNeural +Gender: Male + +Name: ko-KR-InJoonNeural +Gender: Male + +Name: ko-KR-SunHiNeural +Gender: Female + +Name: lo-LA-ChanthavongNeural +Gender: Male + +Name: lo-LA-KeomanyNeural +Gender: Female + +Name: lt-LT-LeonasNeural +Gender: Male + +Name: lt-LT-OnaNeural +Gender: Female + +Name: lv-LV-EveritaNeural +Gender: Female + +Name: lv-LV-NilsNeural +Gender: Male + +Name: mk-MK-AleksandarNeural +Gender: Male + +Name: mk-MK-MarijaNeural +Gender: Female + +Name: ml-IN-MidhunNeural +Gender: Male + +Name: ml-IN-SobhanaNeural +Gender: Female + +Name: mn-MN-BataaNeural +Gender: Male + +Name: mn-MN-YesuiNeural +Gender: Female + +Name: mr-IN-AarohiNeural +Gender: Female + +Name: mr-IN-ManoharNeural +Gender: Male + +Name: ms-MY-OsmanNeural +Gender: Male + +Name: ms-MY-YasminNeural +Gender: Female + +Name: mt-MT-GraceNeural +Gender: Female + +Name: mt-MT-JosephNeural +Gender: Male + +Name: my-MM-NilarNeural +Gender: Female + +Name: my-MM-ThihaNeural +Gender: Male + +Name: nb-NO-FinnNeural +Gender: Male + +Name: nb-NO-PernilleNeural +Gender: Female + +Name: ne-NP-HemkalaNeural +Gender: Female + +Name: ne-NP-SagarNeural +Gender: Male + +Name: nl-BE-ArnaudNeural +Gender: Male + +Name: nl-BE-DenaNeural +Gender: Female + +Name: nl-NL-ColetteNeural +Gender: Female + +Name: nl-NL-FennaNeural +Gender: Female + +Name: nl-NL-MaartenNeural +Gender: Male + +Name: pl-PL-MarekNeural +Gender: Male + +Name: pl-PL-ZofiaNeural +Gender: Female + +Name: ps-AF-GulNawazNeural +Gender: Male + +Name: ps-AF-LatifaNeural +Gender: Female + +Name: pt-BR-AntonioNeural +Gender: Male + +Name: pt-BR-FranciscaNeural +Gender: Female + +Name: pt-BR-ThalitaNeural +Gender: Female + +Name: pt-PT-DuarteNeural +Gender: Male + +Name: pt-PT-RaquelNeural +Gender: Female + +Name: ro-RO-AlinaNeural +Gender: Female + +Name: ro-RO-EmilNeural +Gender: Male + +Name: ru-RU-DmitryNeural +Gender: Male + +Name: ru-RU-SvetlanaNeural +Gender: Female + +Name: si-LK-SameeraNeural +Gender: Male + +Name: si-LK-ThiliniNeural +Gender: Female + +Name: sk-SK-LukasNeural +Gender: Male + +Name: sk-SK-ViktoriaNeural +Gender: Female + +Name: sl-SI-PetraNeural +Gender: Female + +Name: sl-SI-RokNeural +Gender: Male + +Name: so-SO-MuuseNeural +Gender: Male + +Name: so-SO-UbaxNeural +Gender: Female + +Name: sq-AL-AnilaNeural +Gender: Female + +Name: sq-AL-IlirNeural +Gender: Male + +Name: sr-RS-NicholasNeural +Gender: Male + +Name: sr-RS-SophieNeural +Gender: Female + +Name: su-ID-JajangNeural +Gender: Male + +Name: su-ID-TutiNeural +Gender: Female + +Name: sv-SE-MattiasNeural +Gender: Male + +Name: sv-SE-SofieNeural +Gender: Female + +Name: sw-KE-RafikiNeural +Gender: Male + +Name: sw-KE-ZuriNeural +Gender: Female + +Name: sw-TZ-DaudiNeural +Gender: Male + +Name: sw-TZ-RehemaNeural +Gender: Female + +Name: ta-IN-PallaviNeural +Gender: Female + +Name: ta-IN-ValluvarNeural +Gender: Male + +Name: ta-LK-KumarNeural +Gender: Male + +Name: ta-LK-SaranyaNeural +Gender: Female + +Name: ta-MY-KaniNeural +Gender: Female + +Name: ta-MY-SuryaNeural +Gender: Male + +Name: ta-SG-AnbuNeural +Gender: Male + +Name: ta-SG-VenbaNeural +Gender: Female + +Name: te-IN-MohanNeural +Gender: Male + +Name: te-IN-ShrutiNeural +Gender: Female + +Name: th-TH-NiwatNeural +Gender: Male + +Name: th-TH-PremwadeeNeural +Gender: Female + +Name: tr-TR-AhmetNeural +Gender: Male + +Name: tr-TR-EmelNeural +Gender: Female + +Name: uk-UA-OstapNeural +Gender: Male + +Name: uk-UA-PolinaNeural +Gender: Female + +Name: ur-IN-GulNeural +Gender: Female + +Name: ur-IN-SalmanNeural +Gender: Male + +Name: ur-PK-AsadNeural +Gender: Male + +Name: ur-PK-UzmaNeural +Gender: Female + +Name: uz-UZ-MadinaNeural +Gender: Female + +Name: uz-UZ-SardorNeural +Gender: Male + +Name: vi-VN-HoaiMyNeural +Gender: Female + +Name: vi-VN-NamMinhNeural +Gender: Male + +Name: zh-CN-XiaoxiaoNeural +Gender: Female + +Name: zh-CN-XiaoyiNeural +Gender: Female + +Name: zh-CN-YunjianNeural +Gender: Male + +Name: zh-CN-YunxiNeural +Gender: Male + +Name: zh-CN-YunxiaNeural +Gender: Male + +Name: zh-CN-YunyangNeural +Gender: Male + +Name: zh-CN-liaoning-XiaobeiNeural +Gender: Female + +Name: zh-CN-shaanxi-XiaoniNeural +Gender: Female + +Name: zh-HK-HiuGaaiNeural +Gender: Female + +Name: zh-HK-HiuMaanNeural +Gender: Female + +Name: zh-HK-WanLungNeural +Gender: Male + +Name: zh-TW-HsiaoChenNeural +Gender: Female + +Name: zh-TW-HsiaoYuNeural +Gender: Female + +Name: zh-TW-YunJheNeural +Gender: Male + +Name: zu-ZA-ThandoNeural +Gender: Female + +Name: zu-ZA-ThembaNeural +Gender: Male + """.strip() + voices = [] + name = '' + for line in voices_str.split("\n"): + line = line.strip() + if not line: + continue + if line.startswith("Name: "): + name = line[6:].strip() + if line.startswith("Gender: "): + gender = line[8:].strip() + if name and gender: + # voices.append({ + # "name": name, + # "gender": gender, + # }) + if filter_locals: + for filter_local in filter_locals: + if name.lower().startswith(filter_local.lower()): + voices.append(f"{name}-{gender}") + else: + voices.append(f"{name}-{gender}") + name = '' + voices.sort() + return voices + + +def parse_voice_name(name: str): + # zh-CN-XiaoyiNeural-Female + # zh-CN-YunxiNeural-Male + name = name.replace("-Female", "").replace("-Male", "").strip() + return name + + +def tts(text: str, voice_name: str, voice_file: str) -> [SubMaker, None]: + text = text.strip() + for i in range(3): + try: + logger.info(f"start, voice name: {voice_name}, try: {i + 1}") + + async def _do() -> SubMaker: + communicate = edge_tts.Communicate(text, voice_name) + sub_maker = edge_tts.SubMaker() + with open(voice_file, "wb") as file: + async for chunk in communicate.stream(): + if chunk["type"] == "audio": + file.write(chunk["data"]) + elif chunk["type"] == "WordBoundary": + sub_maker.create_sub((chunk["offset"], chunk["duration"]), chunk["text"]) + return sub_maker + + sub_maker = asyncio.run(_do()) + if not sub_maker or not sub_maker.subs: + logger.warning(f"failed, sub_maker is None or sub_maker.subs is None") + continue + + logger.info(f"completed, output file: {voice_file}") + return sub_maker + except Exception as e: + logger.error(f"failed, error: {str(e)}") + return None def create_subtitle(sub_maker: submaker.SubMaker, text: str, subtitle_file: str): @@ -37,6 +1026,14 @@ def create_subtitle(sub_maker: submaker.SubMaker, text: str, subtitle_file: str) 2. 逐行匹配字幕文件中的文本 3. 生成新的字幕文件 """ + text = text.replace("\n", " ") + text = text.replace("[", " ") + text = text.replace("]", " ") + text = text.replace("(", " ") + text = text.replace(")", " ") + text = text.replace("{", " ") + text = text.replace("}", " ") + text = text.strip() def formatter(idx: int, start_time: float, end_time: float, sub_text: str) -> str: """ @@ -57,8 +1054,26 @@ def create_subtitle(sub_maker: submaker.SubMaker, text: str, subtitle_file: str) sub_index = 0 script_lines = utils.split_string_by_punctuations(text) - # remove space in every word - script_lines_without_space = [line.replace(" ", "") for line in script_lines] + + def match_line(_sub_line: str, _sub_index: int): + if len(script_lines) <= _sub_index: + return "" + + _line = script_lines[_sub_index] + if _sub_line == _line: + return script_lines[_sub_index].strip() + + _sub_line_ = re.sub(r"[^\w\s]", "", _sub_line) + _line_ = re.sub(r"[^\w\s]", "", _line) + if _sub_line_ == _line_: + return _line_.strip() + + _sub_line_ = re.sub(r"\W+", "", _sub_line) + _line_ = re.sub(r"\W+", "", _line) + if _sub_line_ == _line_: + return _line.strip() + + return "" sub_line = "" @@ -70,8 +1085,8 @@ def create_subtitle(sub_maker: submaker.SubMaker, text: str, subtitle_file: str) sub = unescape(sub) sub_line += sub - if sub_line == script_lines[sub_index] or sub_line == script_lines_without_space[sub_index]: - sub_text = script_lines[sub_index] + sub_text = match_line(sub_line, sub_index) + if sub_text: sub_index += 1 line = formatter( idx=sub_index, @@ -79,13 +1094,22 @@ def create_subtitle(sub_maker: submaker.SubMaker, text: str, subtitle_file: str) end_time=end_time, sub_text=sub_text, ) - # logger.debug(line.strip()) sub_items.append(line) start_time = -1.0 sub_line = "" - with open(subtitle_file, "w", encoding="utf-8") as file: - file.write("\n".join(sub_items) + "\n") + if len(sub_items) == len(script_lines): + with open(subtitle_file, "w", encoding="utf-8") as file: + file.write("\n".join(sub_items) + "\n") + try: + sbs = subtitles.file_to_subtitles(subtitle_file) + duration = max([tb for ((ta, tb), txt) in sbs]) + logger.info(f"completed, subtitle file created: {subtitle_file}, duration: {duration}") + except Exception as e: + logger.error(f"failed, error: {str(e)}") + os.remove(subtitle_file) + else: + logger.warning(f"failed, sub_items len: {len(sub_items)}, script_lines len: {len(script_lines)}") except Exception as e: logger.error(f"failed, error: {str(e)}") @@ -101,6 +1125,10 @@ def get_audio_duration(sub_maker: submaker.SubMaker): if __name__ == "__main__": + voices = get_all_voices() + print(voices) + print(len(voices)) + async def _do(): temp_dir = utils.storage_dir("temp") @@ -114,11 +1142,20 @@ if __name__ == "__main__": "zh-CN-YunxiNeural", ] text = """ - 预计未来3天深圳冷空气活动频繁,未来两天持续阴天有小雨,出门带好雨具; - 10-11日持续阴天有小雨,日温差小,气温在13-17℃之间,体感阴凉; - 12日天气短暂好转,早晚清凉; + 静夜思是唐代诗人李白创作的一首五言古诗。这首诗描绘了诗人在寂静的夜晚,看到窗前的明月,不禁想起远方的家乡和亲人,表达了他对家乡和亲人的深深思念之情。全诗内容是:“床前明月光,疑是地上霜。举头望明月,低头思故乡。”在这短短的四句诗中,诗人通过“明月”和“思故乡”的意象,巧妙地表达了离乡背井人的孤独与哀愁。首句“床前明月光”设景立意,通过明亮的月光引出诗人的遐想;“疑是地上霜”增添了夜晚的寒冷感,加深了诗人的孤寂之情;“举头望明月”和“低头思故乡”则是情感的升华,展现了诗人内心深处的乡愁和对家的渴望。这首诗简洁明快,情感真挚,是中国古典诗歌中非常著名的一首,也深受后人喜爱和推崇。 """ + text = """ + What is the meaning of life? This question has puzzled philosophers, scientists, and thinkers of all kinds for centuries. Throughout history, various cultures and individuals have come up with their interpretations and beliefs around the purpose of life. Some say it's to seek happiness and self-fulfillment, while others believe it's about contributing to the welfare of others and making a positive impact in the world. Despite the myriad of perspectives, one thing remains clear: the meaning of life is a deeply personal concept that varies from one person to another. It's an existential inquiry that encourages us to reflect on our values, desires, and the essence of our existence. + """ + + text = """ + 预计未来3天深圳冷空气活动频繁,未来两天持续阴天有小雨,出门带好雨具; + 10-11日持续阴天有小雨,日温差小,气温在13-17℃之间,体感阴凉; + 12日天气短暂好转,早晚清凉; + """ + + text = "[Opening scene: A sunny day in a suburban neighborhood. A young boy named Alex, around 8 years old, is playing in his front yard with his loyal dog, Buddy.]\n\n[Camera zooms in on Alex as he throws a ball for Buddy to fetch. Buddy excitedly runs after it and brings it back to Alex.]\n\nAlex: Good boy, Buddy! You're the best dog ever!\n\n[Buddy barks happily and wags his tail.]\n\n[As Alex and Buddy continue playing, a series of potential dangers loom nearby, such as a stray dog approaching, a ball rolling towards the street, and a suspicious-looking stranger walking by.]\n\nAlex: Uh oh, Buddy, look out!\n\n[Buddy senses the danger and immediately springs into action. He barks loudly at the stray dog, scaring it away. Then, he rushes to retrieve the ball before it reaches the street and gently nudges it back towards Alex. Finally, he stands protectively between Alex and the stranger, growling softly to warn them away.]\n\nAlex: Wow, Buddy, you're like my superhero!\n\n[Just as Alex and Buddy are about to head inside, they hear a loud crash from a nearby construction site. They rush over to investigate and find a pile of rubble blocking the path of a kitten trapped underneath.]\n\nAlex: Oh no, Buddy, we have to help!\n\n[Buddy barks in agreement and together they work to carefully move the rubble aside, allowing the kitten to escape unharmed. The kitten gratefully nuzzles against Buddy, who responds with a friendly lick.]\n\nAlex: We did it, Buddy! We saved the day again!\n\n[As Alex and Buddy walk home together, the sun begins to set, casting a warm glow over the neighborhood.]\n\nAlex: Thanks for always being there to watch over me, Buddy. You're not just my dog, you're my best friend.\n\n[Buddy barks happily and nuzzles against Alex as they disappear into the sunset, ready to face whatever adventures tomorrow may bring.]\n\n[End scene.]" for voice_name in voice_names: voice_file = f"{temp_dir}/tts-{voice_name}.mp3" subtitle_file = f"{temp_dir}/tts.mp3.srt" diff --git a/app/utils/utils.py b/app/utils/utils.py index 0dad08c..91a4433 100644 --- a/app/utils/utils.py +++ b/app/utils/utils.py @@ -149,7 +149,7 @@ def text_to_srt(idx: int, msg: str, start_time: float, end_time: float) -> str: def str_contains_punctuation(word): - for p in const.punctuations: + for p in const.PUNCTUATIONS: if p in word: return True return False @@ -159,7 +159,7 @@ def split_string_by_punctuations(s): result = [] txt = "" for char in s: - if char not in const.punctuations: + if char not in const.PUNCTUATIONS: txt += char else: result.append(txt.strip()) diff --git a/config.example.toml b/config.example.toml index 7abdf94..19d98f4 100644 --- a/config.example.toml +++ b/config.example.toml @@ -16,12 +16,15 @@ # g4f # azure # qwen (通义千问) + # gemini llm_provider="openai" ########## OpenAI API Key - # Visit https://openai.com/api/ for details on obtaining an API key. + # Get your API key at https://platform.openai.com/api-keys openai_api_key = "" - openai_base_url = "" # no need to set it unless you want to use your own proxy + # No need to set it unless you want to use your own proxy + openai_base_url = "" + # Check your available models at https://platform.openai.com/account/limits openai_model_name = "gpt-4-turbo-preview" ########## Moonshot API Key @@ -49,6 +52,10 @@ azure_model_name="gpt-35-turbo" # replace with your model deployment name azure_api_version = "2024-02-15-preview" + ########## Gemini API Key + gemini_api_key="" + gemini_model_name = "gemini-1.0-pro" + ########## Qwen API Key # Visit https://dashscope.console.aliyun.com/apiKey to get your API key # Visit below links to get more details @@ -90,6 +97,20 @@ # ffmpeg_path = "C:\\Users\\harry\\Downloads\\ffmpeg.exe" ######################################################################################### + # 当视频生成成功后,API服务提供的视频下载接入点,默认为当前服务的地址和监听端口 + # 比如 http://127.0.0.1:8080/tasks/6357f542-a4e1-46a1-b4c9-bf3bd0df5285/final-1.mp4 + # 如果你需要使用域名对外提供服务(一般会用nginx做代理),则可以设置为你的域名 + # 比如 https://xxxx.com/tasks/6357f542-a4e1-46a1-b4c9-bf3bd0df5285/final-1.mp4 + # endpoint="https://xxxx.com" + + # 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. + # For example, http://127.0.0.1:8080/tasks/6357f542-a4e1-46a1-b4c9-bf3bd0df5285/final-1.mp4 + # 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. + # For example, https://xxxx.com/tasks/6357f542-a4e1-46a1-b4c9-bf3bd0df5285/final-1.mp4 + # endpoint="https://xxxx.com" + endpoint="" + + [whisper] # Only effective when subtitle_provider is "whisper" diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..8c932bf --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,27 @@ +version: "3" + +x-common-volumes: &common-volumes + - ./config.toml:/MoneyPrinterTurbo/config.toml + - ./storage:/MoneyPrinterTurbo/storage + +services: + webui: + build: + context: . + dockerfile: Dockerfile + container_name: "webui" + ports: + - "8501:8501" + command: ["streamlit", "run", "./webui/Main.py","--browser.serverAddress=0.0.0.0","--server.enableCORS=True","--browser.gatherUsageStats=False"] + volumes: *common-volumes + restart: always + api: + build: + context: . + dockerfile: Dockerfile + container_name: "api" + ports: + - "8080:8080" + command: [ "python3", "main.py" ] + volumes: *common-volumes + restart: always \ No newline at end of file diff --git a/docs/api.jpg b/docs/api.jpg index 6ee51d2..e9a4122 100644 Binary files a/docs/api.jpg and b/docs/api.jpg differ diff --git a/docs/webui-en.jpg b/docs/webui-en.jpg new file mode 100644 index 0000000..d68245c Binary files /dev/null and b/docs/webui-en.jpg differ diff --git a/docs/webui.jpg b/docs/webui.jpg index 1259775..387102f 100644 Binary files a/docs/webui.jpg and b/docs/webui.jpg differ diff --git a/main.py b/main.py index b7b8ab5..a0fddeb 100644 --- a/main.py +++ b/main.py @@ -3,6 +3,6 @@ from loguru import logger from app.config import config if __name__ == '__main__': - logger.info("start server, docs: http://127.0.0.1:" + str(config.listen_port) + "/docs") + logger.info("start server, docs: http://0.0.0.0:" + str(config.listen_port) + "/docs") uvicorn.run(app="app.asgi:app", host=config.listen_host, port=config.listen_port, reload=config.reload_debug, log_level="warning") diff --git a/requirements.txt b/requirements.txt index 651f46a..d67aa75 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,4 +13,5 @@ urllib3~=2.2.1 pillow~=9.5.0 pydantic~=2.6.3 g4f~=0.2.5.4 -dashscope~=1.15.0 \ No newline at end of file +dashscope~=1.15.0 +google.generativeai~=0.4.1 \ No newline at end of file diff --git a/resource/public/index.html b/resource/public/index.html new file mode 100644 index 0000000..45e8037 --- /dev/null +++ b/resource/public/index.html @@ -0,0 +1,19 @@ + + + + + MoneyPrinterTurbo + + +

MoneyPrinterTurbo

+https://github.com/harry0703/MoneyPrinterTurbo +

+ 只需提供一个视频 主题 或 关键词 ,就可以全自动生成视频文案、视频素材、视频字幕、视频背景音乐,然后合成一个高清的短视频。 +

+ +

+ Simply provide a topic or keyword for a video, and it will automatically generate the video copy, video materials, + video subtitles, and video background music before synthesizing a high-definition short video. +

+ + \ No newline at end of file diff --git a/webui/Main.py b/webui/Main.py index ecbec8f..4d8483f 100644 --- a/webui/Main.py +++ b/webui/Main.py @@ -1,29 +1,68 @@ +import json +import locale import streamlit as st - -st.set_page_config(page_title="MoneyPrinterTurbo", page_icon="🤖", layout="wide", - initial_sidebar_state="auto") import sys import os from uuid import uuid4 - +import platform +import streamlit.components.v1 as components +import toml from loguru import logger -from app.models.schema import VideoParams, VideoAspect, VoiceNames, VideoConcatMode -from app.services import task as tm, llm + +st.set_page_config(page_title="MoneyPrinterTurbo", + page_icon="🤖", + layout="wide", + initial_sidebar_state="auto", + menu_items={ + 'Report a bug': "https://github.com/harry0703/MoneyPrinterTurbo/issues", + 'About': "# MoneyPrinterTurbo\nSimply provide a topic or keyword for a video, and it will " + "automatically generate the video copy, video materials, video subtitles, " + "and video background music before synthesizing a high-definition short " + "video.\n\nhttps://github.com/harry0703/MoneyPrinterTurbo" + }) + +from app.models.schema import VideoParams, VideoAspect, VideoConcatMode +from app.services import task as tm, llm, voice +from app.utils import utils hide_streamlit_style = """ """ st.markdown(hide_streamlit_style, unsafe_allow_html=True) st.title("MoneyPrinterTurbo") -st.write( - "⚠️ 先在 **config.toml** 中设置 `pexels_api_keys` 和 `llm_provider` 参数,根据不同的 llm_provider,配置对应的 **API KEY**" -) root_dir = os.path.dirname(os.path.dirname(os.path.realpath(__file__))) font_dir = os.path.join(root_dir, "resource", "fonts") song_dir = os.path.join(root_dir, "resource", "songs") +i18n_dir = os.path.join(root_dir, "webui", "i18n") +config_file = os.path.join(root_dir, "webui", ".streamlit", "webui.toml") + + +def load_config() -> dict: + try: + return toml.load(config_file) + except Exception as e: + return {} + + +cfg = load_config() + + +def save_config(): + with open(config_file, "w", encoding="utf-8") as f: + f.write(toml.dumps(cfg)) + + +def get_system_locale(): + try: + loc = locale.getdefaultlocale() + # zh_CN, zh_TW return zh + # en_US, en_GB return en + language_code = loc[0].split("_")[0] + return language_code + except Exception as e: + return "en" -# st.session_state if 'video_subject' not in st.session_state: st.session_state['video_subject'] = '' @@ -31,6 +70,8 @@ if 'video_script' not in st.session_state: st.session_state['video_script'] = '' if 'video_terms' not in st.session_state: st.session_state['video_terms'] = '' +if 'ui_language' not in st.session_state: + st.session_state['ui_language'] = cfg.get("ui_language", get_system_locale()) def get_all_fonts(): @@ -51,6 +92,36 @@ def get_all_songs(): return songs +def open_task_folder(task_id): + try: + sys = platform.system() + path = os.path.join(root_dir, "storage", "tasks", task_id) + if os.path.exists(path): + if sys == 'Windows': + os.system(f"start {path}") + if sys == 'Darwin': + os.system(f"open {path}") + except Exception as e: + logger.error(e) + + +def scroll_to_bottom(): + js = f""" + + """ + st.components.v1.html(js, height=0, width=0) + + def init_log(): logger.remove() _lvl = "DEBUG" @@ -82,114 +153,154 @@ def init_log(): init_log() + +def load_locales(): + locales = {} + for root, dirs, files in os.walk(i18n_dir): + for file in files: + if file.endswith(".json"): + lang = file.split(".")[0] + with open(os.path.join(root, file), "r", encoding="utf-8") as f: + locales[lang] = json.loads(f.read()) + return locales + + +locales = load_locales() + + +def tr(key): + loc = locales.get(st.session_state['ui_language'], {}) + return loc.get("Translation", {}).get(key, key) + + +display_languages = [] +selected_index = 0 +for i, code in enumerate(locales.keys()): + display_languages.append(f"{code} - {locales[code].get('Language')}") + if code == st.session_state['ui_language']: + selected_index = i + +selected_language = st.selectbox("Language", options=display_languages, label_visibility='collapsed', + index=selected_index) +if selected_language: + code = selected_language.split(" - ")[0].strip() + st.session_state['ui_language'] = code + cfg['ui_language'] = code + save_config() + panel = st.columns(3) left_panel = panel[0] middle_panel = panel[1] right_panel = panel[2] -# define cfg as VideoParams class -cfg = VideoParams() +params = VideoParams() with left_panel: with st.container(border=True): - st.write("**文案设置**") - cfg.video_subject = st.text_input("视频主题(给定一个关键词,:red[AI自动生成]视频文案)", - value=st.session_state['video_subject']).strip() + st.write(tr("Video Script Settings")) + params.video_subject = st.text_input(tr("Video Subject"), + value=st.session_state['video_subject']).strip() video_languages = [ - ("自动判断(Auto detect)", ""), + (tr("Auto Detect"), ""), ] - for lang in ["zh-CN", "zh-TW", "en-US"]: - video_languages.append((lang, lang)) + for code in ["zh-CN", "zh-TW", "de-DE", "en-US"]: + video_languages.append((code, code)) - selected_index = st.selectbox("生成视频脚本的语言(:blue[一般情况AI会自动根据你输入的主题语言输出])", + selected_index = st.selectbox(tr("Script Language"), index=0, options=range(len(video_languages)), # 使用索引作为内部选项值 format_func=lambda x: video_languages[x][0] # 显示给用户的是标签 ) - cfg.video_language = video_languages[selected_index][1] + params.video_language = video_languages[selected_index][1] - if cfg.video_language: - st.write(f"设置AI输出文案语言为: **:red[{cfg.video_language}]**") - - if st.button("点击使用AI根据**主题**生成 【视频文案】 和 【视频关键词】", key="auto_generate_script"): - with st.spinner("AI正在生成视频文案和关键词..."): - script = llm.generate_script(video_subject=cfg.video_subject, language=cfg.video_language) - terms = llm.generate_terms(cfg.video_subject, script) - st.toast('AI生成成功') + if st.button(tr("Generate Video Script and Keywords"), key="auto_generate_script"): + with st.spinner(tr("Generating Video Script and Keywords")): + script = llm.generate_script(video_subject=params.video_subject, language=params.video_language) + terms = llm.generate_terms(params.video_subject, script) st.session_state['video_script'] = script st.session_state['video_terms'] = ", ".join(terms) - cfg.video_script = st.text_area( - "视频文案(:blue[①可不填,使用AI生成 ②合理使用标点断句,有助于生成字幕])", + params.video_script = st.text_area( + tr("Video Script"), value=st.session_state['video_script'], - height=230 + height=180 ) - if st.button("点击使用AI根据**文案**生成【视频关键词】", key="auto_generate_terms"): - if not cfg.video_script: - st.error("请先填写视频文案") + if st.button(tr("Generate Video Keywords"), key="auto_generate_terms"): + if not params.video_script: + st.error(tr("Please Enter the Video Subject")) st.stop() - with st.spinner("AI正在生成视频关键词..."): - terms = llm.generate_terms(cfg.video_subject, cfg.video_script) - st.toast('AI生成成功') + with st.spinner(tr("Generating Video Keywords")): + terms = llm.generate_terms(params.video_subject, params.video_script) st.session_state['video_terms'] = ", ".join(terms) - cfg.video_terms = st.text_area( - "视频关键词(:blue[①可不填,使用AI生成 ②用**英文逗号**分隔,只支持英文])", + params.video_terms = st.text_area( + tr("Video Keywords"), value=st.session_state['video_terms'], height=50) with middle_panel: with st.container(border=True): - st.write("**视频设置**") + st.write(tr("Video Settings")) video_concat_modes = [ - ("顺序拼接", "sequential"), - ("随机拼接(推荐)", "random"), + (tr("Sequential"), "sequential"), + (tr("Random"), "random"), ] - selected_index = st.selectbox("视频拼接模式", + selected_index = st.selectbox(tr("Video Concat Mode"), index=1, options=range(len(video_concat_modes)), # 使用索引作为内部选项值 format_func=lambda x: video_concat_modes[x][0] # 显示给用户的是标签 ) - cfg.video_concat_mode = VideoConcatMode(video_concat_modes[selected_index][1]) + params.video_concat_mode = VideoConcatMode(video_concat_modes[selected_index][1]) video_aspect_ratios = [ - ("竖屏 9:16(抖音视频)", VideoAspect.portrait.value), - ("横屏 16:9(西瓜视频)", VideoAspect.landscape.value), - # ("方形 1:1", VideoAspect.square.value) + (tr("Portrait"), VideoAspect.portrait.value), + (tr("Landscape"), VideoAspect.landscape.value), ] - selected_index = st.selectbox("视频比例", + selected_index = st.selectbox(tr("Video Ratio"), options=range(len(video_aspect_ratios)), # 使用索引作为内部选项值 format_func=lambda x: video_aspect_ratios[x][0] # 显示给用户的是标签 ) - cfg.video_aspect = VideoAspect(video_aspect_ratios[selected_index][1]) + params.video_aspect = VideoAspect(video_aspect_ratios[selected_index][1]) - cfg.video_clip_duration = st.selectbox("视频片段最大时长(秒)", options=[2, 3, 4, 5, 6], index=1) - cfg.video_count = st.selectbox("同时生成视频数量", options=[1, 2, 3, 4, 5], index=0) + params.video_clip_duration = st.selectbox(tr("Clip Duration"), options=[2, 3, 4, 5, 6], index=1) + params.video_count = st.selectbox(tr("Number of Videos Generated Simultaneously"), options=[1, 2, 3, 4, 5], + index=0) with st.container(border=True): - st.write("**音频设置**") - # 创建一个映射字典,将原始值映射到友好名称 + st.write(tr("Audio Settings")) + voices = voice.get_all_voices(filter_locals=["zh-CN", "zh-HK", "zh-TW", "de-DE", "en-US"]) friendly_names = { voice: voice. - replace("female", "女性"). - replace("male", "男性"). - replace("zh-CN", "中文"). - replace("zh-HK", "香港"). - replace("zh-TW", "台湾"). - replace("en-US", "英文"). + replace("Female", tr("Female")). + replace("Male", tr("Male")). replace("Neural", "") for - voice in VoiceNames} - selected_friendly_name = st.selectbox("朗读声音", options=list(friendly_names.values())) + voice in voices} + saved_voice_name = cfg.get("voice_name", "") + saved_voice_name_index = 0 + if saved_voice_name in friendly_names: + saved_voice_name_index = list(friendly_names.keys()).index(saved_voice_name) + else: + for i, voice in enumerate(voices): + if voice.lower().startswith(st.session_state['ui_language'].lower()): + saved_voice_name_index = i + break + + selected_friendly_name = st.selectbox(tr("Speech Synthesis"), + options=list(friendly_names.values()), + index=saved_voice_name_index) + voice_name = list(friendly_names.keys())[list(friendly_names.values()).index(selected_friendly_name)] - cfg.voice_name = voice_name + params.voice_name = voice_name + cfg['voice_name'] = voice_name + save_config() bgm_options = [ - ("无背景音乐 No BGM", ""), - ("随机背景音乐 Random BGM", "random"), - ("自定义背景音乐 Custom BGM", "custom"), + (tr("No Background Music"), ""), + (tr("Random Background Music"), "random"), + (tr("Custom Background Music"), "custom"), ] - selected_index = st.selectbox("背景音乐", + selected_index = st.selectbox(tr("Background Music"), index=1, options=range(len(bgm_options)), # 使用索引作为内部选项值 format_func=lambda x: bgm_options[x][0] # 显示给用户的是标签 @@ -199,55 +310,53 @@ with middle_panel: # 根据选择显示或隐藏组件 if bgm_type == "custom": - custom_bgm_file = st.text_input("请输入自定义背景音乐的文件路径:") + custom_bgm_file = st.text_input(tr("Custom Background Music File")) if custom_bgm_file and os.path.exists(custom_bgm_file): - cfg.bgm_file = custom_bgm_file + params.bgm_file = custom_bgm_file # st.write(f":red[已选择自定义背景音乐]:**{custom_bgm_file}**") - cfg.bgm_volume = st.selectbox("背景音乐音量(0.2表示20%,背景声音不宜过高)", - options=[0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0], index=2) + params.bgm_volume = st.selectbox(tr("Background Music Volume"), + options=[0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0], index=2) with right_panel: with st.container(border=True): - st.write("**字幕设置**") - cfg.subtitle_enabled = st.checkbox("生成字幕(若取消勾选,下面的设置都将不生效)", value=True) + st.write(tr("Subtitle Settings")) + params.subtitle_enabled = st.checkbox(tr("Enable Subtitles"), value=True) font_names = get_all_fonts() - cfg.font_name = st.selectbox("字体", font_names) + params.font_name = st.selectbox(tr("Font"), font_names) subtitle_positions = [ - ("顶部(top)", "top"), - ("居中(center)", "center"), - ("底部(bottom,推荐)", "bottom"), + (tr("Top"), "top"), + (tr("Center"), "center"), + (tr("Bottom"), "bottom"), ] - selected_index = st.selectbox("字幕位置", + selected_index = st.selectbox(tr("Position"), index=2, options=range(len(subtitle_positions)), # 使用索引作为内部选项值 format_func=lambda x: subtitle_positions[x][0] # 显示给用户的是标签 ) - cfg.subtitle_position = subtitle_positions[selected_index][1] + params.subtitle_position = subtitle_positions[selected_index][1] font_cols = st.columns([0.3, 0.7]) with font_cols[0]: - cfg.text_fore_color = st.color_picker("字幕颜色", "#FFFFFF") + params.text_fore_color = st.color_picker(tr("Font Color"), "#FFFFFF") with font_cols[1]: - cfg.font_size = st.slider("字幕大小", 30, 100, 60) + params.font_size = st.slider(tr("Font Size"), 30, 100, 60) stroke_cols = st.columns([0.3, 0.7]) with stroke_cols[0]: - cfg.stroke_color = st.color_picker("描边颜色", "#000000") + params.stroke_color = st.color_picker(tr("Stroke Color"), "#000000") with stroke_cols[1]: - cfg.stroke_width = st.slider("描边粗细", 0.0, 10.0, 1.5) + params.stroke_width = st.slider(tr("Stroke Width"), 0.0, 10.0, 1.5) -start_button = st.button("开始生成视频", use_container_width=True, type="primary") +start_button = st.button(tr("Generate Video"), use_container_width=True, type="primary") if start_button: task_id = str(uuid4()) - if not cfg.video_subject and not cfg.video_script: - st.error("视频主题 或 视频文案,不能同时为空") + if not params.video_subject and not params.video_script: + st.error(tr("Video Script and Subject Cannot Both Be Empty")) + scroll_to_bottom() st.stop() - st.write(cfg) - log_container = st.empty() - log_records = [] @@ -259,6 +368,11 @@ if start_button: logger.add(log_received) - logger.info("开始生成视频") + st.toast(tr("Generating Video")) + logger.info(tr("Start Generating Video")) + logger.info(utils.to_json(params)) + scroll_to_bottom() - tm.start(task_id=task_id, params=cfg) + tm.start(task_id=task_id, params=params) + open_task_folder(task_id) + logger.info(tr("Video Generation Completed")) diff --git a/webui/i18n/de.json b/webui/i18n/de.json new file mode 100644 index 0000000..a7d7994 --- /dev/null +++ b/webui/i18n/de.json @@ -0,0 +1,51 @@ +{ + "Language": "German", + "Translation": { + "Video Script Settings": "**Drehbuch / Topic des Videos**", + "Video Subject": "Worum soll es in dem Video gehen? (Geben Sie ein Keyword an, :red[Dank KI wird automatisch ein Drehbuch generieren])", + "Script Language": "Welche Sprache soll zum Generieren von Drehbüchern verwendet werden? :red[KI generiert anhand dieses Begriffs das Drehbuch]", + "Generate Video Script and Keywords": "Klicken Sie hier, um mithilfe von KI ein [Video Drehbuch] und [Video Keywords] basierend auf dem **Keyword** zu generieren.", + "Auto Detect": "Automatisch erkennen", + "Video Script": "Drehbuch (Storybook) (:blue[① Optional, KI generiert ② Die richtige Zeichensetzung hilft bei der Erstellung von Untertiteln])", + "Generate Video Keywords": "Klicken Sie, um KI zum Generieren zu verwenden [Video Keywords] basierend auf dem **Drehbuch**", + "Please Enter the Video Subject": "Bitte geben Sie zuerst das Drehbuch an", + "Generating Video Script and Keywords": "KI generiert ein Drehbuch und Schlüsselwörter...", + "Generating Video Keywords": "AI is generating video keywords...", + "Video Keywords": "Video Schlüsselwörter (:blue[① Optional, KI generiert ② Verwende **, (Kommas)** zur Trennung der Wörter, in englischer Sprache])", + "Video Settings": "**Video Einstellungen**", + "Video Concat Mode": "Videoverkettungsmodus", + "Random": "Zufällige Verkettung (empfohlen)", + "Sequential": "Sequentielle Verkettung", + "Video Ratio": "Video-Seitenverhältnis", + "Portrait": "Portrait 9:16", + "Landscape": "Landschaft 16:9", + "Clip Duration": "Maximale Dauer einzelner Videoclips in sekunden", + "Number of Videos Generated Simultaneously": "Anzahl der parallel generierten Videos", + "Audio Settings": "**Audio Einstellungen**", + "Speech Synthesis": "Sprachausgabe", + "Male": "Männlich", + "Female": "Weiblich", + "Background Music": "Hintergrundmusik", + "No Background Music": "Ohne Hintergrundmusik", + "Random Background Music": "Zufällig erzeugte Hintergrundmusik", + "Custom Background Music": "Benutzerdefinierte Hintergrundmusik", + "Custom Background Music File": "Bitte gib den Pfad zur Musikdatei an:", + "Background Music Volume": "Lautstärke: (0.2 entspricht 20%, sollte nicht zu laut sein)", + "Subtitle Settings": "**Untertitel-Einstellungen**", + "Enable Subtitles": "Untertitel aktivieren (Wenn diese Option deaktiviert ist, werden die Einstellungen nicht genutzt)", + "Font": "Schriftart des Untertitels", + "Position": "Ausrichtung des Untertitels", + "Top": "Oben", + "Center": "Mittig", + "Bottom": "Unten (empfohlen)", + "Font Size": "Schriftgröße für Untertitel", + "Font Color": "Schriftfarbe", + "Stroke Color": "Kontur", + "Stroke Width": "Breite der Untertitelkontur", + "Generate Video": "Generiere Videos durch KI", + "Video Script and Subject Cannot Both Be Empty": "Das Video-Thema und Drehbuch dürfen nicht beide leer sein", + "Generating Video": "Video wird erstellt, bitte warten...", + "Start Generating Video": "Beginne mit der Generierung", + "Video Generation Completed": "Video erfolgreich generiert" + } +} \ No newline at end of file diff --git a/webui/i18n/en.json b/webui/i18n/en.json new file mode 100644 index 0000000..964f316 --- /dev/null +++ b/webui/i18n/en.json @@ -0,0 +1,51 @@ +{ + "Language": "English", + "Translation": { + "Video Script Settings": "**Video Script Settings**", + "Video Subject": "Video Subject (Provide a keyword, :red[AI will automatically generate] video script)", + "Script Language": "Language for Generating Video Script (AI will automatically output based on the language of your subject)", + "Generate Video Script and Keywords": "Click to use AI to generate [Video Script] and [Video Keywords] based on **subject**", + "Auto Detect": "Auto Detect", + "Video Script": "Video Script (:blue[① Optional, AI generated ② Proper punctuation helps with subtitle generation])", + "Generate Video Keywords": "Click to use AI to generate [Video Keywords] based on **script**", + "Please Enter the Video Subject": "Please Enter the Video Script First", + "Generating Video Script and Keywords": "AI is generating video script and keywords...", + "Generating Video Keywords": "AI is generating video keywords...", + "Video Keywords": "Video Keywords (:blue[① Optional, AI generated ② Use **English commas** for separation, English only])", + "Video Settings": "**Video Settings**", + "Video Concat Mode": "Video Concatenation Mode", + "Random": "Random Concatenation (Recommended)", + "Sequential": "Sequential Concatenation", + "Video Ratio": "Video Aspect Ratio", + "Portrait": "Portrait 9:16", + "Landscape": "Landscape 16:9", + "Clip Duration": "Maximum Duration of Video Clips (seconds)", + "Number of Videos Generated Simultaneously": "Number of Videos Generated Simultaneously", + "Audio Settings": "**Audio Settings**", + "Speech Synthesis": "Speech Synthesis Voice", + "Male": "Male", + "Female": "Female", + "Background Music": "Background Music", + "No Background Music": "No Background Music", + "Random Background Music": "Random Background Music", + "Custom Background Music": "Custom Background Music", + "Custom Background Music File": "Please enter the file path for custom background music:", + "Background Music Volume": "Background Music Volume (0.2 represents 20%, background music should not be too loud)", + "Subtitle Settings": "**Subtitle Settings**", + "Enable Subtitles": "Enable Subtitles (If unchecked, the settings below will not take effect)", + "Font": "Subtitle Font", + "Position": "Subtitle Position", + "Top": "Top", + "Center": "Center", + "Bottom": "Bottom (Recommended)", + "Font Size": "Subtitle Font Size", + "Font Color": "Subtitle Font Color", + "Stroke Color": "Subtitle Outline Color", + "Stroke Width": "Subtitle Outline Width", + "Generate Video": "Generate Video", + "Video Script and Subject Cannot Both Be Empty": "Video Subject and Video Script cannot both be empty", + "Generating Video": "Generating video, please wait...", + "Start Generating Video": "Start Generating Video", + "Video Generation Completed": "Video Generation Completed" + } +} \ No newline at end of file diff --git a/webui/i18n/zh.json b/webui/i18n/zh.json new file mode 100644 index 0000000..269c725 --- /dev/null +++ b/webui/i18n/zh.json @@ -0,0 +1,51 @@ +{ + "Language": "简体中文", + "Translation": { + "Video Script Settings": "**文案设置**", + "Video Subject": "视频主题(给定一个关键词,:red[AI自动生成]视频文案)", + "Script Language": "生成视频脚本的语言(一般情况AI会自动根据你输入的主题语言输出)", + "Generate Video Script and Keywords": "点击使用AI根据**主题**生成 【视频文案】 和 【视频关键词】", + "Auto Detect": "自动检测", + "Video Script": "视频文案(:blue[①可不填,使用AI生成 ②合理使用标点断句,有助于生成字幕])", + "Generate Video Keywords": "点击使用AI根据**文案**生成【视频关键词】", + "Please Enter the Video Subject": "请先填写视频文案", + "Generating Video Script and Keywords": "AI正在生成视频文案和关键词...", + "Generating Video Keywords": "AI正在生成视频关键词...", + "Video Keywords": "视频关键词(:blue[①可不填,使用AI生成 ②用**英文逗号**分隔,只支持英文])", + "Video Settings": "**视频设置**", + "Video Concat Mode": "视频拼接模式", + "Random": "随机拼接(推荐)", + "Sequential": "顺序拼接", + "Video Ratio": "视频比例", + "Portrait": "竖屏 9:16(抖音视频)", + "Landscape": "横屏 16:9(西瓜视频)", + "Clip Duration": "视频片段最大时长(秒)", + "Number of Videos Generated Simultaneously": "同时生成视频数量", + "Audio Settings": "**音频设置**", + "Speech Synthesis": "朗读声音", + "Male": "男性", + "Female": "女性", + "Background Music": "背景音乐", + "No Background Music": "无背景音乐", + "Random Background Music": "随机背景音乐", + "Custom Background Music": "自定义背景音乐", + "Custom Background Music File": "请输入自定义背景音乐的文件路径", + "Background Music Volume": "背景音乐音量(0.2表示20%,背景声音不宜过高)", + "Subtitle Settings": "**字幕设置**", + "Enable Subtitles": "启用字幕(若取消勾选,下面的设置都将不生效)", + "Font": "字幕字体", + "Position": "字幕位置", + "Top": "顶部", + "Center": "中间", + "Bottom": "底部(推荐)", + "Font Size": "字幕大小", + "Font Color": "字幕颜色", + "Stroke Color": "描边颜色", + "Stroke Width": "描边粗细", + "Generate Video": "生成视频", + "Video Script and Subject Cannot Both Be Empty": "视频主题 和 视频文案,不能同时为空", + "Generating Video": "正在生成视频,请稍候...", + "Start Generating Video": "开始生成视频", + "Video Generation Completed": "视频生成完成" + } +} \ No newline at end of file