Upload 108 files
Browse filesThis view is limited to 50 files because it contains too many changes.
See raw diff
- .gitattributes +42 -0
- Dockerfile +44 -0
- LICENSE +21 -0
- README-en.md +389 -0
- README.md +369 -10
- app/__init__.py +0 -0
- app/asgi.py +82 -0
- app/config/__init__.py +56 -0
- app/config/config.py +78 -0
- app/controllers/base.py +31 -0
- app/controllers/manager/base_manager.py +64 -0
- app/controllers/manager/memory_manager.py +18 -0
- app/controllers/manager/redis_manager.py +56 -0
- app/controllers/ping.py +13 -0
- app/controllers/v1/base.py +11 -0
- app/controllers/v1/llm.py +45 -0
- app/controllers/v1/video.py +287 -0
- app/models/__init__.py +0 -0
- app/models/const.py +25 -0
- app/models/exception.py +28 -0
- app/models/schema.py +303 -0
- app/router.py +17 -0
- app/services/__init__.py +0 -0
- app/services/llm.py +444 -0
- app/services/material.py +267 -0
- app/services/state.py +158 -0
- app/services/subtitle.py +299 -0
- app/services/task.py +339 -0
- app/services/utils/video_effects.py +21 -0
- app/services/video.py +531 -0
- app/services/voice.py +1566 -0
- app/utils/utils.py +230 -0
- config.example.toml +214 -0
- docker-compose.yml +24 -0
- docs/MoneyPrinterTurbo.ipynb +118 -0
- docs/api.jpg +3 -0
- docs/picwish.com.jpg +3 -0
- docs/picwish.jpg +3 -0
- docs/reccloud.cn.jpg +3 -0
- docs/reccloud.com.jpg +3 -0
- docs/voice-list.txt +941 -0
- docs/webui-en.jpg +3 -0
- docs/webui.jpg +3 -0
- main.py +16 -0
- requirements.txt +16 -0
- resource/fonts/Charm-Bold.ttf +3 -0
- resource/fonts/Charm-Regular.ttf +3 -0
- resource/fonts/MicrosoftYaHeiBold.ttc +3 -0
- resource/fonts/MicrosoftYaHeiNormal.ttc +3 -0
- resource/fonts/STHeitiLight.ttc +3 -0
.gitattributes
CHANGED
@@ -33,3 +33,45 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
|
33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
36 |
+
docs/api.jpg filter=lfs diff=lfs merge=lfs -text
|
37 |
+
docs/picwish.com.jpg filter=lfs diff=lfs merge=lfs -text
|
38 |
+
docs/picwish.jpg filter=lfs diff=lfs merge=lfs -text
|
39 |
+
docs/reccloud.cn.jpg filter=lfs diff=lfs merge=lfs -text
|
40 |
+
docs/reccloud.com.jpg filter=lfs diff=lfs merge=lfs -text
|
41 |
+
docs/webui-en.jpg filter=lfs diff=lfs merge=lfs -text
|
42 |
+
docs/webui.jpg filter=lfs diff=lfs merge=lfs -text
|
43 |
+
resource/fonts/Charm-Bold.ttf filter=lfs diff=lfs merge=lfs -text
|
44 |
+
resource/fonts/Charm-Regular.ttf filter=lfs diff=lfs merge=lfs -text
|
45 |
+
resource/fonts/MicrosoftYaHeiBold.ttc filter=lfs diff=lfs merge=lfs -text
|
46 |
+
resource/fonts/MicrosoftYaHeiNormal.ttc filter=lfs diff=lfs merge=lfs -text
|
47 |
+
resource/fonts/STHeitiLight.ttc filter=lfs diff=lfs merge=lfs -text
|
48 |
+
resource/fonts/STHeitiMedium.ttc filter=lfs diff=lfs merge=lfs -text
|
49 |
+
resource/songs/output000.mp3 filter=lfs diff=lfs merge=lfs -text
|
50 |
+
resource/songs/output001.mp3 filter=lfs diff=lfs merge=lfs -text
|
51 |
+
resource/songs/output002.mp3 filter=lfs diff=lfs merge=lfs -text
|
52 |
+
resource/songs/output003.mp3 filter=lfs diff=lfs merge=lfs -text
|
53 |
+
resource/songs/output004.mp3 filter=lfs diff=lfs merge=lfs -text
|
54 |
+
resource/songs/output005.mp3 filter=lfs diff=lfs merge=lfs -text
|
55 |
+
resource/songs/output006.mp3 filter=lfs diff=lfs merge=lfs -text
|
56 |
+
resource/songs/output007.mp3 filter=lfs diff=lfs merge=lfs -text
|
57 |
+
resource/songs/output008.mp3 filter=lfs diff=lfs merge=lfs -text
|
58 |
+
resource/songs/output009.mp3 filter=lfs diff=lfs merge=lfs -text
|
59 |
+
resource/songs/output010.mp3 filter=lfs diff=lfs merge=lfs -text
|
60 |
+
resource/songs/output011.mp3 filter=lfs diff=lfs merge=lfs -text
|
61 |
+
resource/songs/output012.mp3 filter=lfs diff=lfs merge=lfs -text
|
62 |
+
resource/songs/output013.mp3 filter=lfs diff=lfs merge=lfs -text
|
63 |
+
resource/songs/output014.mp3 filter=lfs diff=lfs merge=lfs -text
|
64 |
+
resource/songs/output015.mp3 filter=lfs diff=lfs merge=lfs -text
|
65 |
+
resource/songs/output016.mp3 filter=lfs diff=lfs merge=lfs -text
|
66 |
+
resource/songs/output017.mp3 filter=lfs diff=lfs merge=lfs -text
|
67 |
+
resource/songs/output018.mp3 filter=lfs diff=lfs merge=lfs -text
|
68 |
+
resource/songs/output019.mp3 filter=lfs diff=lfs merge=lfs -text
|
69 |
+
resource/songs/output020.mp3 filter=lfs diff=lfs merge=lfs -text
|
70 |
+
resource/songs/output021.mp3 filter=lfs diff=lfs merge=lfs -text
|
71 |
+
resource/songs/output022.mp3 filter=lfs diff=lfs merge=lfs -text
|
72 |
+
resource/songs/output023.mp3 filter=lfs diff=lfs merge=lfs -text
|
73 |
+
resource/songs/output024.mp3 filter=lfs diff=lfs merge=lfs -text
|
74 |
+
resource/songs/output025.mp3 filter=lfs diff=lfs merge=lfs -text
|
75 |
+
resource/songs/output027.mp3 filter=lfs diff=lfs merge=lfs -text
|
76 |
+
resource/songs/output028.mp3 filter=lfs diff=lfs merge=lfs -text
|
77 |
+
resource/songs/output029.mp3 filter=lfs diff=lfs merge=lfs -text
|
Dockerfile
ADDED
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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
ADDED
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
MIT License
|
2 |
+
|
3 |
+
Copyright (c) 2024 Harry
|
4 |
+
|
5 |
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
6 |
+
of this software and associated documentation files (the "Software"), to deal
|
7 |
+
in the Software without restriction, including without limitation the rights
|
8 |
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
9 |
+
copies of the Software, and to permit persons to whom the Software is
|
10 |
+
furnished to do so, subject to the following conditions:
|
11 |
+
|
12 |
+
The above copyright notice and this permission notice shall be included in all
|
13 |
+
copies or substantial portions of the Software.
|
14 |
+
|
15 |
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
16 |
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
17 |
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
18 |
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
19 |
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
20 |
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
21 |
+
SOFTWARE.
|
README-en.md
ADDED
@@ -0,0 +1,389 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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)
|
README.md
CHANGED
@@ -1,10 +1,369 @@
|
|
1 |
-
|
2 |
-
|
3 |
-
|
4 |
-
|
5 |
-
|
6 |
-
|
7 |
-
|
8 |
-
|
9 |
-
|
10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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
ADDED
File without changes
|
app/asgi.py
ADDED
@@ -0,0 +1,82 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"""Application implementation - ASGI."""
|
2 |
+
|
3 |
+
import os
|
4 |
+
|
5 |
+
from fastapi import FastAPI, Request
|
6 |
+
from fastapi.exceptions import RequestValidationError
|
7 |
+
from fastapi.middleware.cors import CORSMiddleware
|
8 |
+
from fastapi.responses import JSONResponse
|
9 |
+
from fastapi.staticfiles import StaticFiles
|
10 |
+
from loguru import logger
|
11 |
+
|
12 |
+
from app.config import config
|
13 |
+
from app.models.exception import HttpException
|
14 |
+
from app.router import root_api_router
|
15 |
+
from app.utils import utils
|
16 |
+
|
17 |
+
|
18 |
+
def exception_handler(request: Request, e: HttpException):
|
19 |
+
return JSONResponse(
|
20 |
+
status_code=e.status_code,
|
21 |
+
content=utils.get_response(e.status_code, e.data, e.message),
|
22 |
+
)
|
23 |
+
|
24 |
+
|
25 |
+
def validation_exception_handler(request: Request, e: RequestValidationError):
|
26 |
+
return JSONResponse(
|
27 |
+
status_code=400,
|
28 |
+
content=utils.get_response(
|
29 |
+
status=400, data=e.errors(), message="field required"
|
30 |
+
),
|
31 |
+
)
|
32 |
+
|
33 |
+
|
34 |
+
def get_application() -> FastAPI:
|
35 |
+
"""Initialize FastAPI application.
|
36 |
+
|
37 |
+
Returns:
|
38 |
+
FastAPI: Application object instance.
|
39 |
+
|
40 |
+
"""
|
41 |
+
instance = FastAPI(
|
42 |
+
title=config.project_name,
|
43 |
+
description=config.project_description,
|
44 |
+
version=config.project_version,
|
45 |
+
debug=False,
|
46 |
+
)
|
47 |
+
instance.include_router(root_api_router)
|
48 |
+
instance.add_exception_handler(HttpException, exception_handler)
|
49 |
+
instance.add_exception_handler(RequestValidationError, validation_exception_handler)
|
50 |
+
return instance
|
51 |
+
|
52 |
+
|
53 |
+
app = get_application()
|
54 |
+
|
55 |
+
# Configures the CORS middleware for the FastAPI app
|
56 |
+
cors_allowed_origins_str = os.getenv("CORS_ALLOWED_ORIGINS", "")
|
57 |
+
origins = cors_allowed_origins_str.split(",") if cors_allowed_origins_str else ["*"]
|
58 |
+
app.add_middleware(
|
59 |
+
CORSMiddleware,
|
60 |
+
allow_origins=origins,
|
61 |
+
allow_credentials=True,
|
62 |
+
allow_methods=["*"],
|
63 |
+
allow_headers=["*"],
|
64 |
+
)
|
65 |
+
|
66 |
+
task_dir = utils.task_dir()
|
67 |
+
app.mount(
|
68 |
+
"/tasks", StaticFiles(directory=task_dir, html=True, follow_symlink=True), name=""
|
69 |
+
)
|
70 |
+
|
71 |
+
public_dir = utils.public_dir()
|
72 |
+
app.mount("/", StaticFiles(directory=public_dir, html=True), name="")
|
73 |
+
|
74 |
+
|
75 |
+
@app.on_event("shutdown")
|
76 |
+
def shutdown_event():
|
77 |
+
logger.info("shutdown event")
|
78 |
+
|
79 |
+
|
80 |
+
@app.on_event("startup")
|
81 |
+
def startup_event():
|
82 |
+
logger.info("startup event")
|
app/config/__init__.py
ADDED
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import os
|
2 |
+
import sys
|
3 |
+
|
4 |
+
from loguru import logger
|
5 |
+
|
6 |
+
from app.config import config
|
7 |
+
from app.utils import utils
|
8 |
+
|
9 |
+
|
10 |
+
def __init_logger():
|
11 |
+
# _log_file = utils.storage_dir("logs/server.log")
|
12 |
+
_lvl = config.log_level
|
13 |
+
root_dir = os.path.dirname(
|
14 |
+
os.path.dirname(os.path.dirname(os.path.realpath(__file__)))
|
15 |
+
)
|
16 |
+
|
17 |
+
def format_record(record):
|
18 |
+
# 获取日志记录中的文件全路径
|
19 |
+
file_path = record["file"].path
|
20 |
+
# 将绝对路径转换为相对于项目根目录的路径
|
21 |
+
relative_path = os.path.relpath(file_path, root_dir)
|
22 |
+
# 更新记录中的文件路径
|
23 |
+
record["file"].path = f"./{relative_path}"
|
24 |
+
# 返回修改后的格式字符串
|
25 |
+
# 您可以根据需要调整这里的格式
|
26 |
+
_format = (
|
27 |
+
"<green>{time:%Y-%m-%d %H:%M:%S}</> | "
|
28 |
+
+ "<level>{level}</> | "
|
29 |
+
+ '"{file.path}:{line}":<blue> {function}</> '
|
30 |
+
+ "- <level>{message}</>"
|
31 |
+
+ "\n"
|
32 |
+
)
|
33 |
+
return _format
|
34 |
+
|
35 |
+
logger.remove()
|
36 |
+
|
37 |
+
logger.add(
|
38 |
+
sys.stdout,
|
39 |
+
level=_lvl,
|
40 |
+
format=format_record,
|
41 |
+
colorize=True,
|
42 |
+
)
|
43 |
+
|
44 |
+
# logger.add(
|
45 |
+
# _log_file,
|
46 |
+
# level=_lvl,
|
47 |
+
# format=format_record,
|
48 |
+
# rotation="00:00",
|
49 |
+
# retention="3 days",
|
50 |
+
# backtrace=True,
|
51 |
+
# diagnose=True,
|
52 |
+
# enqueue=True,
|
53 |
+
# )
|
54 |
+
|
55 |
+
|
56 |
+
__init_logger()
|
app/config/config.py
ADDED
@@ -0,0 +1,78 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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}")
|
app/controllers/base.py
ADDED
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from uuid import uuid4
|
2 |
+
|
3 |
+
from fastapi import Request
|
4 |
+
|
5 |
+
from app.config import config
|
6 |
+
from app.models.exception import HttpException
|
7 |
+
|
8 |
+
|
9 |
+
def get_task_id(request: Request):
|
10 |
+
task_id = request.headers.get("x-task-id")
|
11 |
+
if not task_id:
|
12 |
+
task_id = uuid4()
|
13 |
+
return str(task_id)
|
14 |
+
|
15 |
+
|
16 |
+
def get_api_key(request: Request):
|
17 |
+
api_key = request.headers.get("x-api-key")
|
18 |
+
return api_key
|
19 |
+
|
20 |
+
|
21 |
+
def verify_token(request: Request):
|
22 |
+
token = get_api_key(request)
|
23 |
+
if token != config.app.get("api_key", ""):
|
24 |
+
request_id = get_task_id(request)
|
25 |
+
request_url = request.url
|
26 |
+
user_agent = request.headers.get("user-agent")
|
27 |
+
raise HttpException(
|
28 |
+
task_id=request_id,
|
29 |
+
status_code=401,
|
30 |
+
message=f"invalid token: {request_url}, {user_agent}",
|
31 |
+
)
|
app/controllers/manager/base_manager.py
ADDED
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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()
|
app/controllers/manager/memory_manager.py
ADDED
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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()
|
app/controllers/manager/redis_manager.py
ADDED
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|
app/controllers/ping.py
ADDED
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from fastapi import APIRouter, Request
|
2 |
+
|
3 |
+
router = APIRouter()
|
4 |
+
|
5 |
+
|
6 |
+
@router.get(
|
7 |
+
"/ping",
|
8 |
+
tags=["Health Check"],
|
9 |
+
description="检查服务可用性",
|
10 |
+
response_description="pong",
|
11 |
+
)
|
12 |
+
def ping(request: Request) -> str:
|
13 |
+
return "pong"
|
app/controllers/v1/base.py
ADDED
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from fastapi import APIRouter
|
2 |
+
|
3 |
+
|
4 |
+
def new_router(dependencies=None):
|
5 |
+
router = APIRouter()
|
6 |
+
router.tags = ["V1"]
|
7 |
+
router.prefix = "/api/v1"
|
8 |
+
# 将认证依赖项应用于所有路由
|
9 |
+
if dependencies:
|
10 |
+
router.dependencies = dependencies
|
11 |
+
return router
|
app/controllers/v1/llm.py
ADDED
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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)
|
app/controllers/v1/video.py
ADDED
@@ -0,0 +1,287 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 |
+
)
|
app/models/__init__.py
ADDED
File without changes
|
app/models/const.py
ADDED
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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"]
|
app/models/exception.py
ADDED
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import traceback
|
2 |
+
from typing import Any
|
3 |
+
|
4 |
+
from loguru import logger
|
5 |
+
|
6 |
+
|
7 |
+
class HttpException(Exception):
|
8 |
+
def __init__(
|
9 |
+
self, task_id: str, status_code: int, message: str = "", data: Any = None
|
10 |
+
):
|
11 |
+
self.message = message
|
12 |
+
self.status_code = status_code
|
13 |
+
self.data = data
|
14 |
+
# Retrieve the exception stack trace information.
|
15 |
+
tb_str = traceback.format_exc().strip()
|
16 |
+
if not tb_str or tb_str == "NoneType: None":
|
17 |
+
msg = f"HttpException: {status_code}, {task_id}, {message}"
|
18 |
+
else:
|
19 |
+
msg = f"HttpException: {status_code}, {task_id}, {message}\n{tb_str}"
|
20 |
+
|
21 |
+
if status_code == 400:
|
22 |
+
logger.warning(msg)
|
23 |
+
else:
|
24 |
+
logger.error(msg)
|
25 |
+
|
26 |
+
|
27 |
+
class FileNotFoundException(Exception):
|
28 |
+
pass
|
app/models/schema.py
ADDED
@@ -0,0 +1,303 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 |
+
}
|
app/router.py
ADDED
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"""Application configuration - root APIRouter.
|
2 |
+
|
3 |
+
Defines all FastAPI application endpoints.
|
4 |
+
|
5 |
+
Resources:
|
6 |
+
1. https://fastapi.tiangolo.com/tutorial/bigger-applications
|
7 |
+
|
8 |
+
"""
|
9 |
+
|
10 |
+
from fastapi import APIRouter
|
11 |
+
|
12 |
+
from app.controllers.v1 import llm, video
|
13 |
+
|
14 |
+
root_api_router = APIRouter()
|
15 |
+
# v1
|
16 |
+
root_api_router.include_router(video.router)
|
17 |
+
root_api_router.include_router(llm.router)
|
app/services/__init__.py
ADDED
File without changes
|
app/services/llm.py
ADDED
@@ -0,0 +1,444 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import json
|
2 |
+
import logging
|
3 |
+
import re
|
4 |
+
import requests
|
5 |
+
from typing import List
|
6 |
+
|
7 |
+
import g4f
|
8 |
+
from loguru import logger
|
9 |
+
from openai import AzureOpenAI, OpenAI
|
10 |
+
from openai.types.chat import ChatCompletion
|
11 |
+
|
12 |
+
from app.config import config
|
13 |
+
|
14 |
+
_max_retries = 5
|
15 |
+
|
16 |
+
|
17 |
+
def _generate_response(prompt: str) -> str:
|
18 |
+
try:
|
19 |
+
content = ""
|
20 |
+
llm_provider = config.app.get("llm_provider", "openai")
|
21 |
+
logger.info(f"llm provider: {llm_provider}")
|
22 |
+
if llm_provider == "g4f":
|
23 |
+
model_name = config.app.get("g4f_model_name", "")
|
24 |
+
if not model_name:
|
25 |
+
model_name = "gpt-3.5-turbo-16k-0613"
|
26 |
+
content = g4f.ChatCompletion.create(
|
27 |
+
model=model_name,
|
28 |
+
messages=[{"role": "user", "content": prompt}],
|
29 |
+
)
|
30 |
+
else:
|
31 |
+
api_version = "" # for azure
|
32 |
+
if llm_provider == "moonshot":
|
33 |
+
api_key = config.app.get("moonshot_api_key")
|
34 |
+
model_name = config.app.get("moonshot_model_name")
|
35 |
+
base_url = "https://api.moonshot.cn/v1"
|
36 |
+
elif llm_provider == "ollama":
|
37 |
+
# api_key = config.app.get("openai_api_key")
|
38 |
+
api_key = "ollama" # any string works but you are required to have one
|
39 |
+
model_name = config.app.get("ollama_model_name")
|
40 |
+
base_url = config.app.get("ollama_base_url", "")
|
41 |
+
if not base_url:
|
42 |
+
base_url = "http://localhost:11434/v1"
|
43 |
+
elif llm_provider == "openai":
|
44 |
+
api_key = config.app.get("openai_api_key")
|
45 |
+
model_name = config.app.get("openai_model_name")
|
46 |
+
base_url = config.app.get("openai_base_url", "")
|
47 |
+
if not base_url:
|
48 |
+
base_url = "https://api.openai.com/v1"
|
49 |
+
elif llm_provider == "oneapi":
|
50 |
+
api_key = config.app.get("oneapi_api_key")
|
51 |
+
model_name = config.app.get("oneapi_model_name")
|
52 |
+
base_url = config.app.get("oneapi_base_url", "")
|
53 |
+
elif llm_provider == "azure":
|
54 |
+
api_key = config.app.get("azure_api_key")
|
55 |
+
model_name = config.app.get("azure_model_name")
|
56 |
+
base_url = config.app.get("azure_base_url", "")
|
57 |
+
api_version = config.app.get("azure_api_version", "2024-02-15-preview")
|
58 |
+
elif llm_provider == "gemini":
|
59 |
+
api_key = config.app.get("gemini_api_key")
|
60 |
+
model_name = config.app.get("gemini_model_name")
|
61 |
+
base_url = "***"
|
62 |
+
elif llm_provider == "qwen":
|
63 |
+
api_key = config.app.get("qwen_api_key")
|
64 |
+
model_name = config.app.get("qwen_model_name")
|
65 |
+
base_url = "***"
|
66 |
+
elif llm_provider == "cloudflare":
|
67 |
+
api_key = config.app.get("cloudflare_api_key")
|
68 |
+
model_name = config.app.get("cloudflare_model_name")
|
69 |
+
account_id = config.app.get("cloudflare_account_id")
|
70 |
+
base_url = "***"
|
71 |
+
elif llm_provider == "deepseek":
|
72 |
+
api_key = config.app.get("deepseek_api_key")
|
73 |
+
model_name = config.app.get("deepseek_model_name")
|
74 |
+
base_url = config.app.get("deepseek_base_url")
|
75 |
+
if not base_url:
|
76 |
+
base_url = "https://api.deepseek.com"
|
77 |
+
elif llm_provider == "ernie":
|
78 |
+
api_key = config.app.get("ernie_api_key")
|
79 |
+
secret_key = config.app.get("ernie_secret_key")
|
80 |
+
base_url = config.app.get("ernie_base_url")
|
81 |
+
model_name = "***"
|
82 |
+
if not secret_key:
|
83 |
+
raise ValueError(
|
84 |
+
f"{llm_provider}: secret_key is not set, please set it in the config.toml file."
|
85 |
+
)
|
86 |
+
elif llm_provider == "pollinations":
|
87 |
+
try:
|
88 |
+
base_url = config.app.get("pollinations_base_url", "")
|
89 |
+
if not base_url:
|
90 |
+
base_url = "https://text.pollinations.ai/openai"
|
91 |
+
model_name = config.app.get("pollinations_model_name", "openai-fast")
|
92 |
+
|
93 |
+
# Prepare the payload
|
94 |
+
payload = {
|
95 |
+
"model": model_name,
|
96 |
+
"messages": [
|
97 |
+
{"role": "user", "content": prompt}
|
98 |
+
],
|
99 |
+
"seed": 101 # Optional but helps with reproducibility
|
100 |
+
}
|
101 |
+
|
102 |
+
# Optional parameters if configured
|
103 |
+
if config.app.get("pollinations_private"):
|
104 |
+
payload["private"] = True
|
105 |
+
if config.app.get("pollinations_referrer"):
|
106 |
+
payload["referrer"] = config.app.get("pollinations_referrer")
|
107 |
+
|
108 |
+
headers = {
|
109 |
+
"Content-Type": "application/json"
|
110 |
+
}
|
111 |
+
|
112 |
+
# Make the API request
|
113 |
+
response = requests.post(base_url, headers=headers, json=payload)
|
114 |
+
response.raise_for_status()
|
115 |
+
result = response.json()
|
116 |
+
|
117 |
+
if result and "choices" in result and len(result["choices"]) > 0:
|
118 |
+
content = result["choices"][0]["message"]["content"]
|
119 |
+
return content.replace("\n", "")
|
120 |
+
else:
|
121 |
+
raise Exception(f"[{llm_provider}] returned an invalid response format")
|
122 |
+
|
123 |
+
except requests.exceptions.RequestException as e:
|
124 |
+
raise Exception(f"[{llm_provider}] request failed: {str(e)}")
|
125 |
+
except Exception as e:
|
126 |
+
raise Exception(f"[{llm_provider}] error: {str(e)}")
|
127 |
+
|
128 |
+
if llm_provider not in ["pollinations", "ollama"]: # Skip validation for providers that don't require API key
|
129 |
+
if not api_key:
|
130 |
+
raise ValueError(
|
131 |
+
f"{llm_provider}: api_key is not set, please set it in the config.toml file."
|
132 |
+
)
|
133 |
+
if not model_name:
|
134 |
+
raise ValueError(
|
135 |
+
f"{llm_provider}: model_name is not set, please set it in the config.toml file."
|
136 |
+
)
|
137 |
+
if not base_url:
|
138 |
+
raise ValueError(
|
139 |
+
f"{llm_provider}: base_url is not set, please set it in the config.toml file."
|
140 |
+
)
|
141 |
+
|
142 |
+
if llm_provider == "qwen":
|
143 |
+
import dashscope
|
144 |
+
from dashscope.api_entities.dashscope_response import GenerationResponse
|
145 |
+
|
146 |
+
dashscope.api_key = api_key
|
147 |
+
response = dashscope.Generation.call(
|
148 |
+
model=model_name, messages=[{"role": "user", "content": prompt}]
|
149 |
+
)
|
150 |
+
if response:
|
151 |
+
if isinstance(response, GenerationResponse):
|
152 |
+
status_code = response.status_code
|
153 |
+
if status_code != 200:
|
154 |
+
raise Exception(
|
155 |
+
f'[{llm_provider}] returned an error response: "{response}"'
|
156 |
+
)
|
157 |
+
|
158 |
+
content = response["output"]["text"]
|
159 |
+
return content.replace("\n", "")
|
160 |
+
else:
|
161 |
+
raise Exception(
|
162 |
+
f'[{llm_provider}] returned an invalid response: "{response}"'
|
163 |
+
)
|
164 |
+
else:
|
165 |
+
raise Exception(f"[{llm_provider}] returned an empty response")
|
166 |
+
|
167 |
+
if llm_provider == "gemini":
|
168 |
+
import google.generativeai as genai
|
169 |
+
|
170 |
+
genai.configure(api_key=api_key, transport="rest")
|
171 |
+
|
172 |
+
generation_config = {
|
173 |
+
"temperature": 0.5,
|
174 |
+
"top_p": 1,
|
175 |
+
"top_k": 1,
|
176 |
+
"max_output_tokens": 2048,
|
177 |
+
}
|
178 |
+
|
179 |
+
safety_settings = [
|
180 |
+
{
|
181 |
+
"category": "HARM_CATEGORY_HARASSMENT",
|
182 |
+
"threshold": "BLOCK_ONLY_HIGH",
|
183 |
+
},
|
184 |
+
{
|
185 |
+
"category": "HARM_CATEGORY_HATE_SPEECH",
|
186 |
+
"threshold": "BLOCK_ONLY_HIGH",
|
187 |
+
},
|
188 |
+
{
|
189 |
+
"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT",
|
190 |
+
"threshold": "BLOCK_ONLY_HIGH",
|
191 |
+
},
|
192 |
+
{
|
193 |
+
"category": "HARM_CATEGORY_DANGEROUS_CONTENT",
|
194 |
+
"threshold": "BLOCK_ONLY_HIGH",
|
195 |
+
},
|
196 |
+
]
|
197 |
+
|
198 |
+
model = genai.GenerativeModel(
|
199 |
+
model_name=model_name,
|
200 |
+
generation_config=generation_config,
|
201 |
+
safety_settings=safety_settings,
|
202 |
+
)
|
203 |
+
|
204 |
+
try:
|
205 |
+
response = model.generate_content(prompt)
|
206 |
+
candidates = response.candidates
|
207 |
+
generated_text = candidates[0].content.parts[0].text
|
208 |
+
except (AttributeError, IndexError) as e:
|
209 |
+
print("Gemini Error:", e)
|
210 |
+
|
211 |
+
return generated_text
|
212 |
+
|
213 |
+
if llm_provider == "cloudflare":
|
214 |
+
response = requests.post(
|
215 |
+
f"https://api.cloudflare.com/client/v4/accounts/{account_id}/ai/run/{model_name}",
|
216 |
+
headers={"Authorization": f"Bearer {api_key}"},
|
217 |
+
json={
|
218 |
+
"messages": [
|
219 |
+
{
|
220 |
+
"role": "system",
|
221 |
+
"content": "You are a friendly assistant",
|
222 |
+
},
|
223 |
+
{"role": "user", "content": prompt},
|
224 |
+
]
|
225 |
+
},
|
226 |
+
)
|
227 |
+
result = response.json()
|
228 |
+
logger.info(result)
|
229 |
+
return result["result"]["response"]
|
230 |
+
|
231 |
+
if llm_provider == "ernie":
|
232 |
+
response = requests.post(
|
233 |
+
"https://aip.baidubce.com/oauth/2.0/token",
|
234 |
+
params={
|
235 |
+
"grant_type": "client_credentials",
|
236 |
+
"client_id": api_key,
|
237 |
+
"client_secret": secret_key,
|
238 |
+
}
|
239 |
+
)
|
240 |
+
access_token = response.json().get("access_token")
|
241 |
+
url = f"{base_url}?access_token={access_token}"
|
242 |
+
|
243 |
+
payload = json.dumps(
|
244 |
+
{
|
245 |
+
"messages": [{"role": "user", "content": prompt}],
|
246 |
+
"temperature": 0.5,
|
247 |
+
"top_p": 0.8,
|
248 |
+
"penalty_score": 1,
|
249 |
+
"disable_search": False,
|
250 |
+
"enable_citation": False,
|
251 |
+
"response_format": "text",
|
252 |
+
}
|
253 |
+
)
|
254 |
+
headers = {"Content-Type": "application/json"}
|
255 |
+
|
256 |
+
response = requests.request(
|
257 |
+
"POST", url, headers=headers, data=payload
|
258 |
+
).json()
|
259 |
+
return response.get("result")
|
260 |
+
|
261 |
+
if llm_provider == "azure":
|
262 |
+
client = AzureOpenAI(
|
263 |
+
api_key=api_key,
|
264 |
+
api_version=api_version,
|
265 |
+
azure_endpoint=base_url,
|
266 |
+
)
|
267 |
+
else:
|
268 |
+
client = OpenAI(
|
269 |
+
api_key=api_key,
|
270 |
+
base_url=base_url,
|
271 |
+
)
|
272 |
+
|
273 |
+
response = client.chat.completions.create(
|
274 |
+
model=model_name, messages=[{"role": "user", "content": prompt}]
|
275 |
+
)
|
276 |
+
if response:
|
277 |
+
if isinstance(response, ChatCompletion):
|
278 |
+
content = response.choices[0].message.content
|
279 |
+
else:
|
280 |
+
raise Exception(
|
281 |
+
f'[{llm_provider}] returned an invalid response: "{response}", please check your network '
|
282 |
+
f"connection and try again."
|
283 |
+
)
|
284 |
+
else:
|
285 |
+
raise Exception(
|
286 |
+
f"[{llm_provider}] returned an empty response, please check your network connection and try again."
|
287 |
+
)
|
288 |
+
|
289 |
+
return content.replace("\n", "")
|
290 |
+
except Exception as e:
|
291 |
+
return f"Error: {str(e)}"
|
292 |
+
|
293 |
+
|
294 |
+
def generate_script(
|
295 |
+
video_subject: str, language: str = "", paragraph_number: int = 1
|
296 |
+
) -> str:
|
297 |
+
prompt = f"""
|
298 |
+
# Role: Video Script Generator
|
299 |
+
|
300 |
+
## Goals:
|
301 |
+
Generate a script for a video, depending on the subject of the video.
|
302 |
+
|
303 |
+
## Constrains:
|
304 |
+
1. the script is to be returned as a string with the specified number of paragraphs.
|
305 |
+
2. do not under any circumstance reference this prompt in your response.
|
306 |
+
3. get straight to the point, don't start with unnecessary things like, "welcome to this video".
|
307 |
+
4. you must not include any type of markdown or formatting in the script, never use a title.
|
308 |
+
5. only return the raw content of the script.
|
309 |
+
6. do not include "voiceover", "narrator" or similar indicators of what should be spoken at the beginning of each paragraph or line.
|
310 |
+
7. you must not mention the prompt, or anything about the script itself. also, never talk about the amount of paragraphs or lines. just write the script.
|
311 |
+
8. respond in the same language as the video subject.
|
312 |
+
|
313 |
+
# Initialization:
|
314 |
+
- video subject: {video_subject}
|
315 |
+
- number of paragraphs: {paragraph_number}
|
316 |
+
""".strip()
|
317 |
+
if language:
|
318 |
+
prompt += f"\n- language: {language}"
|
319 |
+
|
320 |
+
final_script = ""
|
321 |
+
logger.info(f"subject: {video_subject}")
|
322 |
+
|
323 |
+
def format_response(response):
|
324 |
+
# Clean the script
|
325 |
+
# Remove asterisks, hashes
|
326 |
+
response = response.replace("*", "")
|
327 |
+
response = response.replace("#", "")
|
328 |
+
|
329 |
+
# Remove markdown syntax
|
330 |
+
response = re.sub(r"\[.*\]", "", response)
|
331 |
+
response = re.sub(r"\(.*\)", "", response)
|
332 |
+
|
333 |
+
# Split the script into paragraphs
|
334 |
+
paragraphs = response.split("\n\n")
|
335 |
+
|
336 |
+
# Select the specified number of paragraphs
|
337 |
+
# selected_paragraphs = paragraphs[:paragraph_number]
|
338 |
+
|
339 |
+
# Join the selected paragraphs into a single string
|
340 |
+
return "\n\n".join(paragraphs)
|
341 |
+
|
342 |
+
for i in range(_max_retries):
|
343 |
+
try:
|
344 |
+
response = _generate_response(prompt=prompt)
|
345 |
+
if response:
|
346 |
+
final_script = format_response(response)
|
347 |
+
else:
|
348 |
+
logging.error("gpt returned an empty response")
|
349 |
+
|
350 |
+
# g4f may return an error message
|
351 |
+
if final_script and "当日额度已消耗完" in final_script:
|
352 |
+
raise ValueError(final_script)
|
353 |
+
|
354 |
+
if final_script:
|
355 |
+
break
|
356 |
+
except Exception as e:
|
357 |
+
logger.error(f"failed to generate script: {e}")
|
358 |
+
|
359 |
+
if i < _max_retries:
|
360 |
+
logger.warning(f"failed to generate video script, trying again... {i + 1}")
|
361 |
+
if "Error: " in final_script:
|
362 |
+
logger.error(f"failed to generate video script: {final_script}")
|
363 |
+
else:
|
364 |
+
logger.success(f"completed: \n{final_script}")
|
365 |
+
return final_script.strip()
|
366 |
+
|
367 |
+
|
368 |
+
def generate_terms(video_subject: str, video_script: str, amount: int = 5) -> List[str]:
|
369 |
+
prompt = f"""
|
370 |
+
# Role: Video Search Terms Generator
|
371 |
+
|
372 |
+
## Goals:
|
373 |
+
Generate {amount} search terms for stock videos, depending on the subject of a video.
|
374 |
+
|
375 |
+
## Constrains:
|
376 |
+
1. the search terms are to be returned as a json-array of strings.
|
377 |
+
2. each search term should consist of 1-3 words, always add the main subject of the video.
|
378 |
+
3. you must only return the json-array of strings. you must not return anything else. you must not return the script.
|
379 |
+
4. the search terms must be related to the subject of the video.
|
380 |
+
5. reply with english search terms only.
|
381 |
+
|
382 |
+
## Output Example:
|
383 |
+
["search term 1", "search term 2", "search term 3","search term 4","search term 5"]
|
384 |
+
|
385 |
+
## Context:
|
386 |
+
### Video Subject
|
387 |
+
{video_subject}
|
388 |
+
|
389 |
+
### Video Script
|
390 |
+
{video_script}
|
391 |
+
|
392 |
+
Please note that you must use English for generating video search terms; Chinese is not accepted.
|
393 |
+
""".strip()
|
394 |
+
|
395 |
+
logger.info(f"subject: {video_subject}")
|
396 |
+
|
397 |
+
search_terms = []
|
398 |
+
response = ""
|
399 |
+
for i in range(_max_retries):
|
400 |
+
try:
|
401 |
+
response = _generate_response(prompt)
|
402 |
+
if "Error: " in response:
|
403 |
+
logger.error(f"failed to generate video script: {response}")
|
404 |
+
return response
|
405 |
+
search_terms = json.loads(response)
|
406 |
+
if not isinstance(search_terms, list) or not all(
|
407 |
+
isinstance(term, str) for term in search_terms
|
408 |
+
):
|
409 |
+
logger.error("response is not a list of strings.")
|
410 |
+
continue
|
411 |
+
|
412 |
+
except Exception as e:
|
413 |
+
logger.warning(f"failed to generate video terms: {str(e)}")
|
414 |
+
if response:
|
415 |
+
match = re.search(r"\[.*]", response)
|
416 |
+
if match:
|
417 |
+
try:
|
418 |
+
search_terms = json.loads(match.group())
|
419 |
+
except Exception as e:
|
420 |
+
logger.warning(f"failed to generate video terms: {str(e)}")
|
421 |
+
pass
|
422 |
+
|
423 |
+
if search_terms and len(search_terms) > 0:
|
424 |
+
break
|
425 |
+
if i < _max_retries:
|
426 |
+
logger.warning(f"failed to generate video terms, trying again... {i + 1}")
|
427 |
+
|
428 |
+
logger.success(f"completed: \n{search_terms}")
|
429 |
+
return search_terms
|
430 |
+
|
431 |
+
|
432 |
+
if __name__ == "__main__":
|
433 |
+
video_subject = "生命的意义是什么"
|
434 |
+
script = generate_script(
|
435 |
+
video_subject=video_subject, language="zh-CN", paragraph_number=1
|
436 |
+
)
|
437 |
+
print("######################")
|
438 |
+
print(script)
|
439 |
+
search_terms = generate_terms(
|
440 |
+
video_subject=video_subject, video_script=script, amount=5
|
441 |
+
)
|
442 |
+
print("######################")
|
443 |
+
print(search_terms)
|
444 |
+
|
app/services/material.py
ADDED
@@ -0,0 +1,267 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 |
+
)
|
app/services/state.py
ADDED
@@ -0,0 +1,158 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 |
+
)
|
app/services/subtitle.py
ADDED
@@ -0,0 +1,299 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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)
|
app/services/task.py
ADDED
@@ -0,0 +1,339 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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")
|
app/services/utils/video_effects.py
ADDED
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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)])
|
app/services/video.py
ADDED
@@ -0,0 +1,531 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import glob
|
2 |
+
import itertools
|
3 |
+
import os
|
4 |
+
import random
|
5 |
+
import gc
|
6 |
+
import shutil
|
7 |
+
from typing import List
|
8 |
+
from loguru import logger
|
9 |
+
from moviepy import (
|
10 |
+
AudioFileClip,
|
11 |
+
ColorClip,
|
12 |
+
CompositeAudioClip,
|
13 |
+
CompositeVideoClip,
|
14 |
+
ImageClip,
|
15 |
+
TextClip,
|
16 |
+
VideoFileClip,
|
17 |
+
afx,
|
18 |
+
concatenate_videoclips,
|
19 |
+
)
|
20 |
+
from moviepy.video.tools.subtitles import SubtitlesClip
|
21 |
+
from PIL import ImageFont
|
22 |
+
|
23 |
+
from app.models import const
|
24 |
+
from app.models.schema import (
|
25 |
+
MaterialInfo,
|
26 |
+
VideoAspect,
|
27 |
+
VideoConcatMode,
|
28 |
+
VideoParams,
|
29 |
+
VideoTransitionMode,
|
30 |
+
)
|
31 |
+
from app.services.utils import video_effects
|
32 |
+
from app.utils import utils
|
33 |
+
|
34 |
+
class SubClippedVideoClip:
|
35 |
+
def __init__(self, file_path, start_time=None, end_time=None, width=None, height=None, duration=None):
|
36 |
+
self.file_path = file_path
|
37 |
+
self.start_time = start_time
|
38 |
+
self.end_time = end_time
|
39 |
+
self.width = width
|
40 |
+
self.height = height
|
41 |
+
if duration is None:
|
42 |
+
self.duration = end_time - start_time
|
43 |
+
else:
|
44 |
+
self.duration = duration
|
45 |
+
|
46 |
+
def __str__(self):
|
47 |
+
return f"SubClippedVideoClip(file_path={self.file_path}, start_time={self.start_time}, end_time={self.end_time}, duration={self.duration}, width={self.width}, height={self.height})"
|
48 |
+
|
49 |
+
|
50 |
+
audio_codec = "aac"
|
51 |
+
video_codec = "libx264"
|
52 |
+
fps = 30
|
53 |
+
|
54 |
+
def close_clip(clip):
|
55 |
+
if clip is None:
|
56 |
+
return
|
57 |
+
|
58 |
+
try:
|
59 |
+
# close main resources
|
60 |
+
if hasattr(clip, 'reader') and clip.reader is not None:
|
61 |
+
clip.reader.close()
|
62 |
+
|
63 |
+
# close audio resources
|
64 |
+
if hasattr(clip, 'audio') and clip.audio is not None:
|
65 |
+
if hasattr(clip.audio, 'reader') and clip.audio.reader is not None:
|
66 |
+
clip.audio.reader.close()
|
67 |
+
del clip.audio
|
68 |
+
|
69 |
+
# close mask resources
|
70 |
+
if hasattr(clip, 'mask') and clip.mask is not None:
|
71 |
+
if hasattr(clip.mask, 'reader') and clip.mask.reader is not None:
|
72 |
+
clip.mask.reader.close()
|
73 |
+
del clip.mask
|
74 |
+
|
75 |
+
# handle child clips in composite clips
|
76 |
+
if hasattr(clip, 'clips') and clip.clips:
|
77 |
+
for child_clip in clip.clips:
|
78 |
+
if child_clip is not clip: # avoid possible circular references
|
79 |
+
close_clip(child_clip)
|
80 |
+
|
81 |
+
# clear clip list
|
82 |
+
if hasattr(clip, 'clips'):
|
83 |
+
clip.clips = []
|
84 |
+
|
85 |
+
except Exception as e:
|
86 |
+
logger.error(f"failed to close clip: {str(e)}")
|
87 |
+
|
88 |
+
del clip
|
89 |
+
gc.collect()
|
90 |
+
|
91 |
+
def delete_files(files: List[str] | str):
|
92 |
+
if isinstance(files, str):
|
93 |
+
files = [files]
|
94 |
+
|
95 |
+
for file in files:
|
96 |
+
try:
|
97 |
+
os.remove(file)
|
98 |
+
except:
|
99 |
+
pass
|
100 |
+
|
101 |
+
def get_bgm_file(bgm_type: str = "random", bgm_file: str = ""):
|
102 |
+
if not bgm_type:
|
103 |
+
return ""
|
104 |
+
|
105 |
+
if bgm_file and os.path.exists(bgm_file):
|
106 |
+
return bgm_file
|
107 |
+
|
108 |
+
if bgm_type == "random":
|
109 |
+
suffix = "*.mp3"
|
110 |
+
song_dir = utils.song_dir()
|
111 |
+
files = glob.glob(os.path.join(song_dir, suffix))
|
112 |
+
return random.choice(files)
|
113 |
+
|
114 |
+
return ""
|
115 |
+
|
116 |
+
|
117 |
+
def combine_videos(
|
118 |
+
combined_video_path: str,
|
119 |
+
video_paths: List[str],
|
120 |
+
audio_file: str,
|
121 |
+
video_aspect: VideoAspect = VideoAspect.portrait,
|
122 |
+
video_concat_mode: VideoConcatMode = VideoConcatMode.random,
|
123 |
+
video_transition_mode: VideoTransitionMode = None,
|
124 |
+
max_clip_duration: int = 5,
|
125 |
+
threads: int = 2,
|
126 |
+
) -> str:
|
127 |
+
audio_clip = AudioFileClip(audio_file)
|
128 |
+
audio_duration = audio_clip.duration
|
129 |
+
logger.info(f"audio duration: {audio_duration} seconds")
|
130 |
+
# Required duration of each clip
|
131 |
+
req_dur = audio_duration / len(video_paths)
|
132 |
+
req_dur = max_clip_duration
|
133 |
+
logger.info(f"maximum clip duration: {req_dur} seconds")
|
134 |
+
output_dir = os.path.dirname(combined_video_path)
|
135 |
+
|
136 |
+
aspect = VideoAspect(video_aspect)
|
137 |
+
video_width, video_height = aspect.to_resolution()
|
138 |
+
|
139 |
+
processed_clips = []
|
140 |
+
subclipped_items = []
|
141 |
+
video_duration = 0
|
142 |
+
for video_path in video_paths:
|
143 |
+
clip = VideoFileClip(video_path)
|
144 |
+
clip_duration = clip.duration
|
145 |
+
clip_w, clip_h = clip.size
|
146 |
+
close_clip(clip)
|
147 |
+
|
148 |
+
start_time = 0
|
149 |
+
|
150 |
+
while start_time < clip_duration:
|
151 |
+
end_time = min(start_time + max_clip_duration, clip_duration)
|
152 |
+
if clip_duration - start_time >= max_clip_duration:
|
153 |
+
subclipped_items.append(SubClippedVideoClip(file_path= video_path, start_time=start_time, end_time=end_time, width=clip_w, height=clip_h))
|
154 |
+
start_time = end_time
|
155 |
+
if video_concat_mode.value == VideoConcatMode.sequential.value:
|
156 |
+
break
|
157 |
+
|
158 |
+
# random subclipped_items order
|
159 |
+
if video_concat_mode.value == VideoConcatMode.random.value:
|
160 |
+
random.shuffle(subclipped_items)
|
161 |
+
|
162 |
+
logger.debug(f"total subclipped items: {len(subclipped_items)}")
|
163 |
+
|
164 |
+
# Add downloaded clips over and over until the duration of the audio (max_duration) has been reached
|
165 |
+
for i, subclipped_item in enumerate(subclipped_items):
|
166 |
+
if video_duration > audio_duration:
|
167 |
+
break
|
168 |
+
|
169 |
+
logger.debug(f"processing clip {i+1}: {subclipped_item.width}x{subclipped_item.height}, current duration: {video_duration:.2f}s, remaining: {audio_duration - video_duration:.2f}s")
|
170 |
+
|
171 |
+
try:
|
172 |
+
clip = VideoFileClip(subclipped_item.file_path).subclipped(subclipped_item.start_time, subclipped_item.end_time)
|
173 |
+
clip_duration = clip.duration
|
174 |
+
# Not all videos are same size, so we need to resize them
|
175 |
+
clip_w, clip_h = clip.size
|
176 |
+
if clip_w != video_width or clip_h != video_height:
|
177 |
+
clip_ratio = clip.w / clip.h
|
178 |
+
video_ratio = video_width / video_height
|
179 |
+
logger.debug(f"resizing clip, source: {clip_w}x{clip_h}, ratio: {clip_ratio:.2f}, target: {video_width}x{video_height}, ratio: {video_ratio:.2f}")
|
180 |
+
|
181 |
+
if clip_ratio == video_ratio:
|
182 |
+
clip = clip.resized(new_size=(video_width, video_height))
|
183 |
+
else:
|
184 |
+
if clip_ratio > video_ratio:
|
185 |
+
scale_factor = video_width / clip_w
|
186 |
+
else:
|
187 |
+
scale_factor = video_height / clip_h
|
188 |
+
|
189 |
+
new_width = int(clip_w * scale_factor)
|
190 |
+
new_height = int(clip_h * scale_factor)
|
191 |
+
|
192 |
+
background = ColorClip(size=(video_width, video_height), color=(0, 0, 0)).with_duration(clip_duration)
|
193 |
+
clip_resized = clip.resized(new_size=(new_width, new_height)).with_position("center")
|
194 |
+
clip = CompositeVideoClip([background, clip_resized])
|
195 |
+
|
196 |
+
shuffle_side = random.choice(["left", "right", "top", "bottom"])
|
197 |
+
if video_transition_mode.value == VideoTransitionMode.none.value:
|
198 |
+
clip = clip
|
199 |
+
elif video_transition_mode.value == VideoTransitionMode.fade_in.value:
|
200 |
+
clip = video_effects.fadein_transition(clip, 1)
|
201 |
+
elif video_transition_mode.value == VideoTransitionMode.fade_out.value:
|
202 |
+
clip = video_effects.fadeout_transition(clip, 1)
|
203 |
+
elif video_transition_mode.value == VideoTransitionMode.slide_in.value:
|
204 |
+
clip = video_effects.slidein_transition(clip, 1, shuffle_side)
|
205 |
+
elif video_transition_mode.value == VideoTransitionMode.slide_out.value:
|
206 |
+
clip = video_effects.slideout_transition(clip, 1, shuffle_side)
|
207 |
+
elif video_transition_mode.value == VideoTransitionMode.shuffle.value:
|
208 |
+
transition_funcs = [
|
209 |
+
lambda c: video_effects.fadein_transition(c, 1),
|
210 |
+
lambda c: video_effects.fadeout_transition(c, 1),
|
211 |
+
lambda c: video_effects.slidein_transition(c, 1, shuffle_side),
|
212 |
+
lambda c: video_effects.slideout_transition(c, 1, shuffle_side),
|
213 |
+
]
|
214 |
+
shuffle_transition = random.choice(transition_funcs)
|
215 |
+
clip = shuffle_transition(clip)
|
216 |
+
|
217 |
+
if clip.duration > max_clip_duration:
|
218 |
+
clip = clip.subclipped(0, max_clip_duration)
|
219 |
+
|
220 |
+
# wirte clip to temp file
|
221 |
+
clip_file = f"{output_dir}/temp-clip-{i+1}.mp4"
|
222 |
+
clip.write_videofile(clip_file, logger=None, fps=fps, codec=video_codec)
|
223 |
+
|
224 |
+
close_clip(clip)
|
225 |
+
|
226 |
+
processed_clips.append(SubClippedVideoClip(file_path=clip_file, duration=clip.duration, width=clip_w, height=clip_h))
|
227 |
+
video_duration += clip.duration
|
228 |
+
|
229 |
+
except Exception as e:
|
230 |
+
logger.error(f"failed to process clip: {str(e)}")
|
231 |
+
|
232 |
+
# loop processed clips until the video duration matches or exceeds the audio duration.
|
233 |
+
if video_duration < audio_duration:
|
234 |
+
logger.warning(f"video duration ({video_duration:.2f}s) is shorter than audio duration ({audio_duration:.2f}s), looping clips to match audio length.")
|
235 |
+
base_clips = processed_clips.copy()
|
236 |
+
for clip in itertools.cycle(base_clips):
|
237 |
+
if video_duration >= audio_duration:
|
238 |
+
break
|
239 |
+
processed_clips.append(clip)
|
240 |
+
video_duration += clip.duration
|
241 |
+
logger.info(f"video duration: {video_duration:.2f}s, audio duration: {audio_duration:.2f}s, looped {len(processed_clips)-len(base_clips)} clips")
|
242 |
+
|
243 |
+
# merge video clips progressively, avoid loading all videos at once to avoid memory overflow
|
244 |
+
logger.info("starting clip merging process")
|
245 |
+
if not processed_clips:
|
246 |
+
logger.warning("no clips available for merging")
|
247 |
+
return combined_video_path
|
248 |
+
|
249 |
+
# if there is only one clip, use it directly
|
250 |
+
if len(processed_clips) == 1:
|
251 |
+
logger.info("using single clip directly")
|
252 |
+
shutil.copy(processed_clips[0].file_path, combined_video_path)
|
253 |
+
delete_files(processed_clips)
|
254 |
+
logger.info("video combining completed")
|
255 |
+
return combined_video_path
|
256 |
+
|
257 |
+
# create initial video file as base
|
258 |
+
base_clip_path = processed_clips[0].file_path
|
259 |
+
temp_merged_video = f"{output_dir}/temp-merged-video.mp4"
|
260 |
+
temp_merged_next = f"{output_dir}/temp-merged-next.mp4"
|
261 |
+
|
262 |
+
# copy first clip as initial merged video
|
263 |
+
shutil.copy(base_clip_path, temp_merged_video)
|
264 |
+
|
265 |
+
# merge remaining video clips one by one
|
266 |
+
for i, clip in enumerate(processed_clips[1:], 1):
|
267 |
+
logger.info(f"merging clip {i}/{len(processed_clips)-1}, duration: {clip.duration:.2f}s")
|
268 |
+
|
269 |
+
try:
|
270 |
+
# load current base video and next clip to merge
|
271 |
+
base_clip = VideoFileClip(temp_merged_video)
|
272 |
+
next_clip = VideoFileClip(clip.file_path)
|
273 |
+
|
274 |
+
# merge these two clips
|
275 |
+
merged_clip = concatenate_videoclips([base_clip, next_clip])
|
276 |
+
|
277 |
+
# save merged result to temp file
|
278 |
+
merged_clip.write_videofile(
|
279 |
+
filename=temp_merged_next,
|
280 |
+
threads=threads,
|
281 |
+
logger=None,
|
282 |
+
temp_audiofile_path=output_dir,
|
283 |
+
audio_codec=audio_codec,
|
284 |
+
fps=fps,
|
285 |
+
)
|
286 |
+
close_clip(base_clip)
|
287 |
+
close_clip(next_clip)
|
288 |
+
close_clip(merged_clip)
|
289 |
+
|
290 |
+
# replace base file with new merged file
|
291 |
+
delete_files(temp_merged_video)
|
292 |
+
os.rename(temp_merged_next, temp_merged_video)
|
293 |
+
|
294 |
+
except Exception as e:
|
295 |
+
logger.error(f"failed to merge clip: {str(e)}")
|
296 |
+
continue
|
297 |
+
|
298 |
+
# after merging, rename final result to target file name
|
299 |
+
os.rename(temp_merged_video, combined_video_path)
|
300 |
+
|
301 |
+
# clean temp files
|
302 |
+
clip_files = [clip.file_path for clip in processed_clips]
|
303 |
+
delete_files(clip_files)
|
304 |
+
|
305 |
+
logger.info("video combining completed")
|
306 |
+
return combined_video_path
|
307 |
+
|
308 |
+
|
309 |
+
def wrap_text(text, max_width, font="Arial", fontsize=60):
|
310 |
+
# Create ImageFont
|
311 |
+
font = ImageFont.truetype(font, fontsize)
|
312 |
+
|
313 |
+
def get_text_size(inner_text):
|
314 |
+
inner_text = inner_text.strip()
|
315 |
+
left, top, right, bottom = font.getbbox(inner_text)
|
316 |
+
return right - left, bottom - top
|
317 |
+
|
318 |
+
width, height = get_text_size(text)
|
319 |
+
if width <= max_width:
|
320 |
+
return text, height
|
321 |
+
|
322 |
+
processed = True
|
323 |
+
|
324 |
+
_wrapped_lines_ = []
|
325 |
+
words = text.split(" ")
|
326 |
+
_txt_ = ""
|
327 |
+
for word in words:
|
328 |
+
_before = _txt_
|
329 |
+
_txt_ += f"{word} "
|
330 |
+
_width, _height = get_text_size(_txt_)
|
331 |
+
if _width <= max_width:
|
332 |
+
continue
|
333 |
+
else:
|
334 |
+
if _txt_.strip() == word.strip():
|
335 |
+
processed = False
|
336 |
+
break
|
337 |
+
_wrapped_lines_.append(_before)
|
338 |
+
_txt_ = f"{word} "
|
339 |
+
_wrapped_lines_.append(_txt_)
|
340 |
+
if processed:
|
341 |
+
_wrapped_lines_ = [line.strip() for line in _wrapped_lines_]
|
342 |
+
result = "\n".join(_wrapped_lines_).strip()
|
343 |
+
height = len(_wrapped_lines_) * height
|
344 |
+
return result, height
|
345 |
+
|
346 |
+
_wrapped_lines_ = []
|
347 |
+
chars = list(text)
|
348 |
+
_txt_ = ""
|
349 |
+
for word in chars:
|
350 |
+
_txt_ += word
|
351 |
+
_width, _height = get_text_size(_txt_)
|
352 |
+
if _width <= max_width:
|
353 |
+
continue
|
354 |
+
else:
|
355 |
+
_wrapped_lines_.append(_txt_)
|
356 |
+
_txt_ = ""
|
357 |
+
_wrapped_lines_.append(_txt_)
|
358 |
+
result = "\n".join(_wrapped_lines_).strip()
|
359 |
+
height = len(_wrapped_lines_) * height
|
360 |
+
return result, height
|
361 |
+
|
362 |
+
|
363 |
+
def generate_video(
|
364 |
+
video_path: str,
|
365 |
+
audio_path: str,
|
366 |
+
subtitle_path: str,
|
367 |
+
output_file: str,
|
368 |
+
params: VideoParams,
|
369 |
+
):
|
370 |
+
aspect = VideoAspect(params.video_aspect)
|
371 |
+
video_width, video_height = aspect.to_resolution()
|
372 |
+
|
373 |
+
logger.info(f"generating video: {video_width} x {video_height}")
|
374 |
+
logger.info(f" ① video: {video_path}")
|
375 |
+
logger.info(f" ② audio: {audio_path}")
|
376 |
+
logger.info(f" ③ subtitle: {subtitle_path}")
|
377 |
+
logger.info(f" ④ output: {output_file}")
|
378 |
+
|
379 |
+
# https://github.com/harry0703/MoneyPrinterTurbo/issues/217
|
380 |
+
# PermissionError: [WinError 32] The process cannot access the file because it is being used by another process: 'final-1.mp4.tempTEMP_MPY_wvf_snd.mp3'
|
381 |
+
# write into the same directory as the output file
|
382 |
+
output_dir = os.path.dirname(output_file)
|
383 |
+
|
384 |
+
font_path = ""
|
385 |
+
if params.subtitle_enabled:
|
386 |
+
if not params.font_name:
|
387 |
+
params.font_name = "STHeitiMedium.ttc"
|
388 |
+
font_path = os.path.join(utils.font_dir(), params.font_name)
|
389 |
+
if os.name == "nt":
|
390 |
+
font_path = font_path.replace("\\", "/")
|
391 |
+
|
392 |
+
logger.info(f" ⑤ font: {font_path}")
|
393 |
+
|
394 |
+
def create_text_clip(subtitle_item):
|
395 |
+
params.font_size = int(params.font_size)
|
396 |
+
params.stroke_width = int(params.stroke_width)
|
397 |
+
phrase = subtitle_item[1]
|
398 |
+
max_width = video_width * 0.9
|
399 |
+
wrapped_txt, txt_height = wrap_text(
|
400 |
+
phrase, max_width=max_width, font=font_path, fontsize=params.font_size
|
401 |
+
)
|
402 |
+
interline = int(params.font_size * 0.25)
|
403 |
+
size=(int(max_width), int(txt_height + params.font_size * 0.25 + (interline * (wrapped_txt.count("\n") + 1))))
|
404 |
+
|
405 |
+
_clip = TextClip(
|
406 |
+
text=wrapped_txt,
|
407 |
+
font=font_path,
|
408 |
+
font_size=params.font_size,
|
409 |
+
color=params.text_fore_color,
|
410 |
+
bg_color=params.text_background_color,
|
411 |
+
stroke_color=params.stroke_color,
|
412 |
+
stroke_width=params.stroke_width,
|
413 |
+
# interline=interline,
|
414 |
+
# size=size,
|
415 |
+
)
|
416 |
+
duration = subtitle_item[0][1] - subtitle_item[0][0]
|
417 |
+
_clip = _clip.with_start(subtitle_item[0][0])
|
418 |
+
_clip = _clip.with_end(subtitle_item[0][1])
|
419 |
+
_clip = _clip.with_duration(duration)
|
420 |
+
if params.subtitle_position == "bottom":
|
421 |
+
_clip = _clip.with_position(("center", video_height * 0.95 - _clip.h))
|
422 |
+
elif params.subtitle_position == "top":
|
423 |
+
_clip = _clip.with_position(("center", video_height * 0.05))
|
424 |
+
elif params.subtitle_position == "custom":
|
425 |
+
# Ensure the subtitle is fully within the screen bounds
|
426 |
+
margin = 10 # Additional margin, in pixels
|
427 |
+
max_y = video_height - _clip.h - margin
|
428 |
+
min_y = margin
|
429 |
+
custom_y = (video_height - _clip.h) * (params.custom_position / 100)
|
430 |
+
custom_y = max(
|
431 |
+
min_y, min(custom_y, max_y)
|
432 |
+
) # Constrain the y value within the valid range
|
433 |
+
_clip = _clip.with_position(("center", custom_y))
|
434 |
+
else: # center
|
435 |
+
_clip = _clip.with_position(("center", "center"))
|
436 |
+
return _clip
|
437 |
+
|
438 |
+
video_clip = VideoFileClip(video_path).without_audio()
|
439 |
+
audio_clip = AudioFileClip(audio_path).with_effects(
|
440 |
+
[afx.MultiplyVolume(params.voice_volume)]
|
441 |
+
)
|
442 |
+
|
443 |
+
def make_textclip(text):
|
444 |
+
return TextClip(
|
445 |
+
text=text,
|
446 |
+
font=font_path,
|
447 |
+
font_size=params.font_size,
|
448 |
+
)
|
449 |
+
|
450 |
+
if subtitle_path and os.path.exists(subtitle_path):
|
451 |
+
sub = SubtitlesClip(
|
452 |
+
subtitles=subtitle_path, encoding="utf-8", make_textclip=make_textclip
|
453 |
+
)
|
454 |
+
text_clips = []
|
455 |
+
for item in sub.subtitles:
|
456 |
+
clip = create_text_clip(subtitle_item=item)
|
457 |
+
text_clips.append(clip)
|
458 |
+
video_clip = CompositeVideoClip([video_clip, *text_clips])
|
459 |
+
|
460 |
+
bgm_file = get_bgm_file(bgm_type=params.bgm_type, bgm_file=params.bgm_file)
|
461 |
+
if bgm_file:
|
462 |
+
try:
|
463 |
+
bgm_clip = AudioFileClip(bgm_file).with_effects(
|
464 |
+
[
|
465 |
+
afx.MultiplyVolume(params.bgm_volume),
|
466 |
+
afx.AudioFadeOut(3),
|
467 |
+
afx.AudioLoop(duration=video_clip.duration),
|
468 |
+
]
|
469 |
+
)
|
470 |
+
audio_clip = CompositeAudioClip([audio_clip, bgm_clip])
|
471 |
+
except Exception as e:
|
472 |
+
logger.error(f"failed to add bgm: {str(e)}")
|
473 |
+
|
474 |
+
video_clip = video_clip.with_audio(audio_clip)
|
475 |
+
video_clip.write_videofile(
|
476 |
+
output_file,
|
477 |
+
audio_codec=audio_codec,
|
478 |
+
temp_audiofile_path=output_dir,
|
479 |
+
threads=params.n_threads or 2,
|
480 |
+
logger=None,
|
481 |
+
fps=fps,
|
482 |
+
)
|
483 |
+
video_clip.close()
|
484 |
+
del video_clip
|
485 |
+
|
486 |
+
|
487 |
+
def preprocess_video(materials: List[MaterialInfo], clip_duration=4):
|
488 |
+
for material in materials:
|
489 |
+
if not material.url:
|
490 |
+
continue
|
491 |
+
|
492 |
+
ext = utils.parse_extension(material.url)
|
493 |
+
try:
|
494 |
+
clip = VideoFileClip(material.url)
|
495 |
+
except Exception:
|
496 |
+
clip = ImageClip(material.url)
|
497 |
+
|
498 |
+
width = clip.size[0]
|
499 |
+
height = clip.size[1]
|
500 |
+
if width < 480 or height < 480:
|
501 |
+
logger.warning(f"low resolution material: {width}x{height}, minimum 480x480 required")
|
502 |
+
continue
|
503 |
+
|
504 |
+
if ext in const.FILE_TYPE_IMAGES:
|
505 |
+
logger.info(f"processing image: {material.url}")
|
506 |
+
# Create an image clip and set its duration to 3 seconds
|
507 |
+
clip = (
|
508 |
+
ImageClip(material.url)
|
509 |
+
.with_duration(clip_duration)
|
510 |
+
.with_position("center")
|
511 |
+
)
|
512 |
+
# Apply a zoom effect using the resize method.
|
513 |
+
# A lambda function is used to make the zoom effect dynamic over time.
|
514 |
+
# The zoom effect starts from the original size and gradually scales up to 120%.
|
515 |
+
# t represents the current time, and clip.duration is the total duration of the clip (3 seconds).
|
516 |
+
# Note: 1 represents 100% size, so 1.2 represents 120% size.
|
517 |
+
zoom_clip = clip.resized(
|
518 |
+
lambda t: 1 + (clip_duration * 0.03) * (t / clip.duration)
|
519 |
+
)
|
520 |
+
|
521 |
+
# Optionally, create a composite video clip containing the zoomed clip.
|
522 |
+
# This is useful when you want to add other elements to the video.
|
523 |
+
final_clip = CompositeVideoClip([zoom_clip])
|
524 |
+
|
525 |
+
# Output the video to a file.
|
526 |
+
video_file = f"{material.url}.mp4"
|
527 |
+
final_clip.write_videofile(video_file, fps=30, logger=None)
|
528 |
+
close_clip(clip)
|
529 |
+
material.url = video_file
|
530 |
+
logger.success(f"image processed: {video_file}")
|
531 |
+
return materials
|
app/services/voice.py
ADDED
@@ -0,0 +1,1566 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import asyncio
|
2 |
+
import os
|
3 |
+
import re
|
4 |
+
from datetime import datetime
|
5 |
+
from typing import Union
|
6 |
+
from xml.sax.saxutils import unescape
|
7 |
+
|
8 |
+
import edge_tts
|
9 |
+
import requests
|
10 |
+
from edge_tts import SubMaker, submaker
|
11 |
+
from edge_tts.submaker import mktimestamp
|
12 |
+
from loguru import logger
|
13 |
+
from moviepy.video.tools import subtitles
|
14 |
+
|
15 |
+
from app.config import config
|
16 |
+
from app.utils import utils
|
17 |
+
|
18 |
+
|
19 |
+
def get_siliconflow_voices() -> list[str]:
|
20 |
+
"""
|
21 |
+
获取硅基流动的声音列表
|
22 |
+
|
23 |
+
Returns:
|
24 |
+
声音列表,格式为 ["siliconflow:FunAudioLLM/CosyVoice2-0.5B:alex", ...]
|
25 |
+
"""
|
26 |
+
# 硅基流动的声音列表和对应的性别(用于显示)
|
27 |
+
voices_with_gender = [
|
28 |
+
("FunAudioLLM/CosyVoice2-0.5B", "alex", "Male"),
|
29 |
+
("FunAudioLLM/CosyVoice2-0.5B", "anna", "Female"),
|
30 |
+
("FunAudioLLM/CosyVoice2-0.5B", "bella", "Female"),
|
31 |
+
("FunAudioLLM/CosyVoice2-0.5B", "benjamin", "Male"),
|
32 |
+
("FunAudioLLM/CosyVoice2-0.5B", "charles", "Male"),
|
33 |
+
("FunAudioLLM/CosyVoice2-0.5B", "claire", "Female"),
|
34 |
+
("FunAudioLLM/CosyVoice2-0.5B", "david", "Male"),
|
35 |
+
("FunAudioLLM/CosyVoice2-0.5B", "diana", "Female"),
|
36 |
+
]
|
37 |
+
|
38 |
+
# 添加siliconflow:前缀,并格式化为显示名称
|
39 |
+
return [
|
40 |
+
f"siliconflow:{model}:{voice}-{gender}"
|
41 |
+
for model, voice, gender in voices_with_gender
|
42 |
+
]
|
43 |
+
|
44 |
+
|
45 |
+
def get_all_azure_voices(filter_locals=None) -> list[str]:
|
46 |
+
azure_voices_str = """
|
47 |
+
Name: af-ZA-AdriNeural
|
48 |
+
Gender: Female
|
49 |
+
|
50 |
+
Name: af-ZA-WillemNeural
|
51 |
+
Gender: Male
|
52 |
+
|
53 |
+
Name: am-ET-AmehaNeural
|
54 |
+
Gender: Male
|
55 |
+
|
56 |
+
Name: am-ET-MekdesNeural
|
57 |
+
Gender: Female
|
58 |
+
|
59 |
+
Name: ar-AE-FatimaNeural
|
60 |
+
Gender: Female
|
61 |
+
|
62 |
+
Name: ar-AE-HamdanNeural
|
63 |
+
Gender: Male
|
64 |
+
|
65 |
+
Name: ar-BH-AliNeural
|
66 |
+
Gender: Male
|
67 |
+
|
68 |
+
Name: ar-BH-LailaNeural
|
69 |
+
Gender: Female
|
70 |
+
|
71 |
+
Name: ar-DZ-AminaNeural
|
72 |
+
Gender: Female
|
73 |
+
|
74 |
+
Name: ar-DZ-IsmaelNeural
|
75 |
+
Gender: Male
|
76 |
+
|
77 |
+
Name: ar-EG-SalmaNeural
|
78 |
+
Gender: Female
|
79 |
+
|
80 |
+
Name: ar-EG-ShakirNeural
|
81 |
+
Gender: Male
|
82 |
+
|
83 |
+
Name: ar-IQ-BasselNeural
|
84 |
+
Gender: Male
|
85 |
+
|
86 |
+
Name: ar-IQ-RanaNeural
|
87 |
+
Gender: Female
|
88 |
+
|
89 |
+
Name: ar-JO-SanaNeural
|
90 |
+
Gender: Female
|
91 |
+
|
92 |
+
Name: ar-JO-TaimNeural
|
93 |
+
Gender: Male
|
94 |
+
|
95 |
+
Name: ar-KW-FahedNeural
|
96 |
+
Gender: Male
|
97 |
+
|
98 |
+
Name: ar-KW-NouraNeural
|
99 |
+
Gender: Female
|
100 |
+
|
101 |
+
Name: ar-LB-LaylaNeural
|
102 |
+
Gender: Female
|
103 |
+
|
104 |
+
Name: ar-LB-RamiNeural
|
105 |
+
Gender: Male
|
106 |
+
|
107 |
+
Name: ar-LY-ImanNeural
|
108 |
+
Gender: Female
|
109 |
+
|
110 |
+
Name: ar-LY-OmarNeural
|
111 |
+
Gender: Male
|
112 |
+
|
113 |
+
Name: ar-MA-JamalNeural
|
114 |
+
Gender: Male
|
115 |
+
|
116 |
+
Name: ar-MA-MounaNeural
|
117 |
+
Gender: Female
|
118 |
+
|
119 |
+
Name: ar-OM-AbdullahNeural
|
120 |
+
Gender: Male
|
121 |
+
|
122 |
+
Name: ar-OM-AyshaNeural
|
123 |
+
Gender: Female
|
124 |
+
|
125 |
+
Name: ar-QA-AmalNeural
|
126 |
+
Gender: Female
|
127 |
+
|
128 |
+
Name: ar-QA-MoazNeural
|
129 |
+
Gender: Male
|
130 |
+
|
131 |
+
Name: ar-SA-HamedNeural
|
132 |
+
Gender: Male
|
133 |
+
|
134 |
+
Name: ar-SA-ZariyahNeural
|
135 |
+
Gender: Female
|
136 |
+
|
137 |
+
Name: ar-SY-AmanyNeural
|
138 |
+
Gender: Female
|
139 |
+
|
140 |
+
Name: ar-SY-LaithNeural
|
141 |
+
Gender: Male
|
142 |
+
|
143 |
+
Name: ar-TN-HediNeural
|
144 |
+
Gender: Male
|
145 |
+
|
146 |
+
Name: ar-TN-ReemNeural
|
147 |
+
Gender: Female
|
148 |
+
|
149 |
+
Name: ar-YE-MaryamNeural
|
150 |
+
Gender: Female
|
151 |
+
|
152 |
+
Name: ar-YE-SalehNeural
|
153 |
+
Gender: Male
|
154 |
+
|
155 |
+
Name: az-AZ-BabekNeural
|
156 |
+
Gender: Male
|
157 |
+
|
158 |
+
Name: az-AZ-BanuNeural
|
159 |
+
Gender: Female
|
160 |
+
|
161 |
+
Name: bg-BG-BorislavNeural
|
162 |
+
Gender: Male
|
163 |
+
|
164 |
+
Name: bg-BG-KalinaNeural
|
165 |
+
Gender: Female
|
166 |
+
|
167 |
+
Name: bn-BD-NabanitaNeural
|
168 |
+
Gender: Female
|
169 |
+
|
170 |
+
Name: bn-BD-PradeepNeural
|
171 |
+
Gender: Male
|
172 |
+
|
173 |
+
Name: bn-IN-BashkarNeural
|
174 |
+
Gender: Male
|
175 |
+
|
176 |
+
Name: bn-IN-TanishaaNeural
|
177 |
+
Gender: Female
|
178 |
+
|
179 |
+
Name: bs-BA-GoranNeural
|
180 |
+
Gender: Male
|
181 |
+
|
182 |
+
Name: bs-BA-VesnaNeural
|
183 |
+
Gender: Female
|
184 |
+
|
185 |
+
Name: ca-ES-EnricNeural
|
186 |
+
Gender: Male
|
187 |
+
|
188 |
+
Name: ca-ES-JoanaNeural
|
189 |
+
Gender: Female
|
190 |
+
|
191 |
+
Name: cs-CZ-AntoninNeural
|
192 |
+
Gender: Male
|
193 |
+
|
194 |
+
Name: cs-CZ-VlastaNeural
|
195 |
+
Gender: Female
|
196 |
+
|
197 |
+
Name: cy-GB-AledNeural
|
198 |
+
Gender: Male
|
199 |
+
|
200 |
+
Name: cy-GB-NiaNeural
|
201 |
+
Gender: Female
|
202 |
+
|
203 |
+
Name: da-DK-ChristelNeural
|
204 |
+
Gender: Female
|
205 |
+
|
206 |
+
Name: da-DK-JeppeNeural
|
207 |
+
Gender: Male
|
208 |
+
|
209 |
+
Name: de-AT-IngridNeural
|
210 |
+
Gender: Female
|
211 |
+
|
212 |
+
Name: de-AT-JonasNeural
|
213 |
+
Gender: Male
|
214 |
+
|
215 |
+
Name: de-CH-JanNeural
|
216 |
+
Gender: Male
|
217 |
+
|
218 |
+
Name: de-CH-LeniNeural
|
219 |
+
Gender: Female
|
220 |
+
|
221 |
+
Name: de-DE-AmalaNeural
|
222 |
+
Gender: Female
|
223 |
+
|
224 |
+
Name: de-DE-ConradNeural
|
225 |
+
Gender: Male
|
226 |
+
|
227 |
+
Name: de-DE-FlorianMultilingualNeural
|
228 |
+
Gender: Male
|
229 |
+
|
230 |
+
Name: de-DE-KatjaNeural
|
231 |
+
Gender: Female
|
232 |
+
|
233 |
+
Name: de-DE-KillianNeural
|
234 |
+
Gender: Male
|
235 |
+
|
236 |
+
Name: de-DE-SeraphinaMultilingualNeural
|
237 |
+
Gender: Female
|
238 |
+
|
239 |
+
Name: el-GR-AthinaNeural
|
240 |
+
Gender: Female
|
241 |
+
|
242 |
+
Name: el-GR-NestorasNeural
|
243 |
+
Gender: Male
|
244 |
+
|
245 |
+
Name: en-AU-NatashaNeural
|
246 |
+
Gender: Female
|
247 |
+
|
248 |
+
Name: en-AU-WilliamNeural
|
249 |
+
Gender: Male
|
250 |
+
|
251 |
+
Name: en-CA-ClaraNeural
|
252 |
+
Gender: Female
|
253 |
+
|
254 |
+
Name: en-CA-LiamNeural
|
255 |
+
Gender: Male
|
256 |
+
|
257 |
+
Name: en-GB-LibbyNeural
|
258 |
+
Gender: Female
|
259 |
+
|
260 |
+
Name: en-GB-MaisieNeural
|
261 |
+
Gender: Female
|
262 |
+
|
263 |
+
Name: en-GB-RyanNeural
|
264 |
+
Gender: Male
|
265 |
+
|
266 |
+
Name: en-GB-SoniaNeural
|
267 |
+
Gender: Female
|
268 |
+
|
269 |
+
Name: en-GB-ThomasNeural
|
270 |
+
Gender: Male
|
271 |
+
|
272 |
+
Name: en-HK-SamNeural
|
273 |
+
Gender: Male
|
274 |
+
|
275 |
+
Name: en-HK-YanNeural
|
276 |
+
Gender: Female
|
277 |
+
|
278 |
+
Name: en-IE-ConnorNeural
|
279 |
+
Gender: Male
|
280 |
+
|
281 |
+
Name: en-IE-EmilyNeural
|
282 |
+
Gender: Female
|
283 |
+
|
284 |
+
Name: en-IN-NeerjaExpressiveNeural
|
285 |
+
Gender: Female
|
286 |
+
|
287 |
+
Name: en-IN-NeerjaNeural
|
288 |
+
Gender: Female
|
289 |
+
|
290 |
+
Name: en-IN-PrabhatNeural
|
291 |
+
Gender: Male
|
292 |
+
|
293 |
+
Name: en-KE-AsiliaNeural
|
294 |
+
Gender: Female
|
295 |
+
|
296 |
+
Name: en-KE-ChilembaNeural
|
297 |
+
Gender: Male
|
298 |
+
|
299 |
+
Name: en-NG-AbeoNeural
|
300 |
+
Gender: Male
|
301 |
+
|
302 |
+
Name: en-NG-EzinneNeural
|
303 |
+
Gender: Female
|
304 |
+
|
305 |
+
Name: en-NZ-MitchellNeural
|
306 |
+
Gender: Male
|
307 |
+
|
308 |
+
Name: en-NZ-MollyNeural
|
309 |
+
Gender: Female
|
310 |
+
|
311 |
+
Name: en-PH-JamesNeural
|
312 |
+
Gender: Male
|
313 |
+
|
314 |
+
Name: en-PH-RosaNeural
|
315 |
+
Gender: Female
|
316 |
+
|
317 |
+
Name: en-SG-LunaNeural
|
318 |
+
Gender: Female
|
319 |
+
|
320 |
+
Name: en-SG-WayneNeural
|
321 |
+
Gender: Male
|
322 |
+
|
323 |
+
Name: en-TZ-ElimuNeural
|
324 |
+
Gender: Male
|
325 |
+
|
326 |
+
Name: en-TZ-ImaniNeural
|
327 |
+
Gender: Female
|
328 |
+
|
329 |
+
Name: en-US-AnaNeural
|
330 |
+
Gender: Female
|
331 |
+
|
332 |
+
Name: en-US-AndrewMultilingualNeural
|
333 |
+
Gender: Male
|
334 |
+
|
335 |
+
Name: en-US-AndrewNeural
|
336 |
+
Gender: Male
|
337 |
+
|
338 |
+
Name: en-US-AriaNeural
|
339 |
+
Gender: Female
|
340 |
+
|
341 |
+
Name: en-US-AvaMultilingualNeural
|
342 |
+
Gender: Female
|
343 |
+
|
344 |
+
Name: en-US-AvaNeural
|
345 |
+
Gender: Female
|
346 |
+
|
347 |
+
Name: en-US-BrianMultilingualNeural
|
348 |
+
Gender: Male
|
349 |
+
|
350 |
+
Name: en-US-BrianNeural
|
351 |
+
Gender: Male
|
352 |
+
|
353 |
+
Name: en-US-ChristopherNeural
|
354 |
+
Gender: Male
|
355 |
+
|
356 |
+
Name: en-US-EmmaMultilingualNeural
|
357 |
+
Gender: Female
|
358 |
+
|
359 |
+
Name: en-US-EmmaNeural
|
360 |
+
Gender: Female
|
361 |
+
|
362 |
+
Name: en-US-EricNeural
|
363 |
+
Gender: Male
|
364 |
+
|
365 |
+
Name: en-US-GuyNeural
|
366 |
+
Gender: Male
|
367 |
+
|
368 |
+
Name: en-US-JennyNeural
|
369 |
+
Gender: Female
|
370 |
+
|
371 |
+
Name: en-US-MichelleNeural
|
372 |
+
Gender: Female
|
373 |
+
|
374 |
+
Name: en-US-RogerNeural
|
375 |
+
Gender: Male
|
376 |
+
|
377 |
+
Name: en-US-SteffanNeural
|
378 |
+
Gender: Male
|
379 |
+
|
380 |
+
Name: en-ZA-LeahNeural
|
381 |
+
Gender: Female
|
382 |
+
|
383 |
+
Name: en-ZA-LukeNeural
|
384 |
+
Gender: Male
|
385 |
+
|
386 |
+
Name: es-AR-ElenaNeural
|
387 |
+
Gender: Female
|
388 |
+
|
389 |
+
Name: es-AR-TomasNeural
|
390 |
+
Gender: Male
|
391 |
+
|
392 |
+
Name: es-BO-MarceloNeural
|
393 |
+
Gender: Male
|
394 |
+
|
395 |
+
Name: es-BO-SofiaNeural
|
396 |
+
Gender: Female
|
397 |
+
|
398 |
+
Name: es-CL-CatalinaNeural
|
399 |
+
Gender: Female
|
400 |
+
|
401 |
+
Name: es-CL-LorenzoNeural
|
402 |
+
Gender: Male
|
403 |
+
|
404 |
+
Name: es-CO-GonzaloNeural
|
405 |
+
Gender: Male
|
406 |
+
|
407 |
+
Name: es-CO-SalomeNeural
|
408 |
+
Gender: Female
|
409 |
+
|
410 |
+
Name: es-CR-JuanNeural
|
411 |
+
Gender: Male
|
412 |
+
|
413 |
+
Name: es-CR-MariaNeural
|
414 |
+
Gender: Female
|
415 |
+
|
416 |
+
Name: es-CU-BelkysNeural
|
417 |
+
Gender: Female
|
418 |
+
|
419 |
+
Name: es-CU-ManuelNeural
|
420 |
+
Gender: Male
|
421 |
+
|
422 |
+
Name: es-DO-EmilioNeural
|
423 |
+
Gender: Male
|
424 |
+
|
425 |
+
Name: es-DO-RamonaNeural
|
426 |
+
Gender: Female
|
427 |
+
|
428 |
+
Name: es-EC-AndreaNeural
|
429 |
+
Gender: Female
|
430 |
+
|
431 |
+
Name: es-EC-LuisNeural
|
432 |
+
Gender: Male
|
433 |
+
|
434 |
+
Name: es-ES-AlvaroNeural
|
435 |
+
Gender: Male
|
436 |
+
|
437 |
+
Name: es-ES-ElviraNeural
|
438 |
+
Gender: Female
|
439 |
+
|
440 |
+
Name: es-ES-XimenaNeural
|
441 |
+
Gender: Female
|
442 |
+
|
443 |
+
Name: es-GQ-JavierNeural
|
444 |
+
Gender: Male
|
445 |
+
|
446 |
+
Name: es-GQ-TeresaNeural
|
447 |
+
Gender: Female
|
448 |
+
|
449 |
+
Name: es-GT-AndresNeural
|
450 |
+
Gender: Male
|
451 |
+
|
452 |
+
Name: es-GT-MartaNeural
|
453 |
+
Gender: Female
|
454 |
+
|
455 |
+
Name: es-HN-CarlosNeural
|
456 |
+
Gender: Male
|
457 |
+
|
458 |
+
Name: es-HN-KarlaNeural
|
459 |
+
Gender: Female
|
460 |
+
|
461 |
+
Name: es-MX-DaliaNeural
|
462 |
+
Gender: Female
|
463 |
+
|
464 |
+
Name: es-MX-JorgeNeural
|
465 |
+
Gender: Male
|
466 |
+
|
467 |
+
Name: es-NI-FedericoNeural
|
468 |
+
Gender: Male
|
469 |
+
|
470 |
+
Name: es-NI-YolandaNeural
|
471 |
+
Gender: Female
|
472 |
+
|
473 |
+
Name: es-PA-MargaritaNeural
|
474 |
+
Gender: Female
|
475 |
+
|
476 |
+
Name: es-PA-RobertoNeural
|
477 |
+
Gender: Male
|
478 |
+
|
479 |
+
Name: es-PE-AlexNeural
|
480 |
+
Gender: Male
|
481 |
+
|
482 |
+
Name: es-PE-CamilaNeural
|
483 |
+
Gender: Female
|
484 |
+
|
485 |
+
Name: es-PR-KarinaNeural
|
486 |
+
Gender: Female
|
487 |
+
|
488 |
+
Name: es-PR-VictorNeural
|
489 |
+
Gender: Male
|
490 |
+
|
491 |
+
Name: es-PY-MarioNeural
|
492 |
+
Gender: Male
|
493 |
+
|
494 |
+
Name: es-PY-TaniaNeural
|
495 |
+
Gender: Female
|
496 |
+
|
497 |
+
Name: es-SV-LorenaNeural
|
498 |
+
Gender: Female
|
499 |
+
|
500 |
+
Name: es-SV-RodrigoNeural
|
501 |
+
Gender: Male
|
502 |
+
|
503 |
+
Name: es-US-AlonsoNeural
|
504 |
+
Gender: Male
|
505 |
+
|
506 |
+
Name: es-US-PalomaNeural
|
507 |
+
Gender: Female
|
508 |
+
|
509 |
+
Name: es-UY-MateoNeural
|
510 |
+
Gender: Male
|
511 |
+
|
512 |
+
Name: es-UY-ValentinaNeural
|
513 |
+
Gender: Female
|
514 |
+
|
515 |
+
Name: es-VE-PaolaNeural
|
516 |
+
Gender: Female
|
517 |
+
|
518 |
+
Name: es-VE-SebastianNeural
|
519 |
+
Gender: Male
|
520 |
+
|
521 |
+
Name: et-EE-AnuNeural
|
522 |
+
Gender: Female
|
523 |
+
|
524 |
+
Name: et-EE-KertNeural
|
525 |
+
Gender: Male
|
526 |
+
|
527 |
+
Name: fa-IR-DilaraNeural
|
528 |
+
Gender: Female
|
529 |
+
|
530 |
+
Name: fa-IR-FaridNeural
|
531 |
+
Gender: Male
|
532 |
+
|
533 |
+
Name: fi-FI-HarriNeural
|
534 |
+
Gender: Male
|
535 |
+
|
536 |
+
Name: fi-FI-NooraNeural
|
537 |
+
Gender: Female
|
538 |
+
|
539 |
+
Name: fil-PH-AngeloNeural
|
540 |
+
Gender: Male
|
541 |
+
|
542 |
+
Name: fil-PH-BlessicaNeural
|
543 |
+
Gender: Female
|
544 |
+
|
545 |
+
Name: fr-BE-CharlineNeural
|
546 |
+
Gender: Female
|
547 |
+
|
548 |
+
Name: fr-BE-GerardNeural
|
549 |
+
Gender: Male
|
550 |
+
|
551 |
+
Name: fr-CA-AntoineNeural
|
552 |
+
Gender: Male
|
553 |
+
|
554 |
+
Name: fr-CA-JeanNeural
|
555 |
+
Gender: Male
|
556 |
+
|
557 |
+
Name: fr-CA-SylvieNeural
|
558 |
+
Gender: Female
|
559 |
+
|
560 |
+
Name: fr-CA-ThierryNeural
|
561 |
+
Gender: Male
|
562 |
+
|
563 |
+
Name: fr-CH-ArianeNeural
|
564 |
+
Gender: Female
|
565 |
+
|
566 |
+
Name: fr-CH-FabriceNeural
|
567 |
+
Gender: Male
|
568 |
+
|
569 |
+
Name: fr-FR-DeniseNeural
|
570 |
+
Gender: Female
|
571 |
+
|
572 |
+
Name: fr-FR-EloiseNeural
|
573 |
+
Gender: Female
|
574 |
+
|
575 |
+
Name: fr-FR-HenriNeural
|
576 |
+
Gender: Male
|
577 |
+
|
578 |
+
Name: fr-FR-RemyMultilingualNeural
|
579 |
+
Gender: Male
|
580 |
+
|
581 |
+
Name: fr-FR-VivienneMultilingualNeural
|
582 |
+
Gender: Female
|
583 |
+
|
584 |
+
Name: ga-IE-ColmNeural
|
585 |
+
Gender: Male
|
586 |
+
|
587 |
+
Name: ga-IE-OrlaNeural
|
588 |
+
Gender: Female
|
589 |
+
|
590 |
+
Name: gl-ES-RoiNeural
|
591 |
+
Gender: Male
|
592 |
+
|
593 |
+
Name: gl-ES-SabelaNeural
|
594 |
+
Gender: Female
|
595 |
+
|
596 |
+
Name: gu-IN-DhwaniNeural
|
597 |
+
Gender: Female
|
598 |
+
|
599 |
+
Name: gu-IN-NiranjanNeural
|
600 |
+
Gender: Male
|
601 |
+
|
602 |
+
Name: he-IL-AvriNeural
|
603 |
+
Gender: Male
|
604 |
+
|
605 |
+
Name: he-IL-HilaNeural
|
606 |
+
Gender: Female
|
607 |
+
|
608 |
+
Name: hi-IN-MadhurNeural
|
609 |
+
Gender: Male
|
610 |
+
|
611 |
+
Name: hi-IN-SwaraNeural
|
612 |
+
Gender: Female
|
613 |
+
|
614 |
+
Name: hr-HR-GabrijelaNeural
|
615 |
+
Gender: Female
|
616 |
+
|
617 |
+
Name: hr-HR-SreckoNeural
|
618 |
+
Gender: Male
|
619 |
+
|
620 |
+
Name: hu-HU-NoemiNeural
|
621 |
+
Gender: Female
|
622 |
+
|
623 |
+
Name: hu-HU-TamasNeural
|
624 |
+
Gender: Male
|
625 |
+
|
626 |
+
Name: id-ID-ArdiNeural
|
627 |
+
Gender: Male
|
628 |
+
|
629 |
+
Name: id-ID-GadisNeural
|
630 |
+
Gender: Female
|
631 |
+
|
632 |
+
Name: is-IS-GudrunNeural
|
633 |
+
Gender: Female
|
634 |
+
|
635 |
+
Name: is-IS-GunnarNeural
|
636 |
+
Gender: Male
|
637 |
+
|
638 |
+
Name: it-IT-DiegoNeural
|
639 |
+
Gender: Male
|
640 |
+
|
641 |
+
Name: it-IT-ElsaNeural
|
642 |
+
Gender: Female
|
643 |
+
|
644 |
+
Name: it-IT-GiuseppeMultilingualNeural
|
645 |
+
Gender: Male
|
646 |
+
|
647 |
+
Name: it-IT-IsabellaNeural
|
648 |
+
Gender: Female
|
649 |
+
|
650 |
+
Name: iu-Cans-CA-SiqiniqNeural
|
651 |
+
Gender: Female
|
652 |
+
|
653 |
+
Name: iu-Cans-CA-TaqqiqNeural
|
654 |
+
Gender: Male
|
655 |
+
|
656 |
+
Name: iu-Latn-CA-SiqiniqNeural
|
657 |
+
Gender: Female
|
658 |
+
|
659 |
+
Name: iu-Latn-CA-TaqqiqNeural
|
660 |
+
Gender: Male
|
661 |
+
|
662 |
+
Name: ja-JP-KeitaNeural
|
663 |
+
Gender: Male
|
664 |
+
|
665 |
+
Name: ja-JP-NanamiNeural
|
666 |
+
Gender: Female
|
667 |
+
|
668 |
+
Name: jv-ID-DimasNeural
|
669 |
+
Gender: Male
|
670 |
+
|
671 |
+
Name: jv-ID-SitiNeural
|
672 |
+
Gender: Female
|
673 |
+
|
674 |
+
Name: ka-GE-EkaNeural
|
675 |
+
Gender: Female
|
676 |
+
|
677 |
+
Name: ka-GE-GiorgiNeural
|
678 |
+
Gender: Male
|
679 |
+
|
680 |
+
Name: kk-KZ-AigulNeural
|
681 |
+
Gender: Female
|
682 |
+
|
683 |
+
Name: kk-KZ-DauletNeural
|
684 |
+
Gender: Male
|
685 |
+
|
686 |
+
Name: km-KH-PisethNeural
|
687 |
+
Gender: Male
|
688 |
+
|
689 |
+
Name: km-KH-SreymomNeural
|
690 |
+
Gender: Female
|
691 |
+
|
692 |
+
Name: kn-IN-GaganNeural
|
693 |
+
Gender: Male
|
694 |
+
|
695 |
+
Name: kn-IN-SapnaNeural
|
696 |
+
Gender: Female
|
697 |
+
|
698 |
+
Name: ko-KR-HyunsuMultilingualNeural
|
699 |
+
Gender: Male
|
700 |
+
|
701 |
+
Name: ko-KR-InJoonNeural
|
702 |
+
Gender: Male
|
703 |
+
|
704 |
+
Name: ko-KR-SunHiNeural
|
705 |
+
Gender: Female
|
706 |
+
|
707 |
+
Name: lo-LA-ChanthavongNeural
|
708 |
+
Gender: Male
|
709 |
+
|
710 |
+
Name: lo-LA-KeomanyNeural
|
711 |
+
Gender: Female
|
712 |
+
|
713 |
+
Name: lt-LT-LeonasNeural
|
714 |
+
Gender: Male
|
715 |
+
|
716 |
+
Name: lt-LT-OnaNeural
|
717 |
+
Gender: Female
|
718 |
+
|
719 |
+
Name: lv-LV-EveritaNeural
|
720 |
+
Gender: Female
|
721 |
+
|
722 |
+
Name: lv-LV-NilsNeural
|
723 |
+
Gender: Male
|
724 |
+
|
725 |
+
Name: mk-MK-AleksandarNeural
|
726 |
+
Gender: Male
|
727 |
+
|
728 |
+
Name: mk-MK-MarijaNeural
|
729 |
+
Gender: Female
|
730 |
+
|
731 |
+
Name: ml-IN-MidhunNeural
|
732 |
+
Gender: Male
|
733 |
+
|
734 |
+
Name: ml-IN-SobhanaNeural
|
735 |
+
Gender: Female
|
736 |
+
|
737 |
+
Name: mn-MN-BataaNeural
|
738 |
+
Gender: Male
|
739 |
+
|
740 |
+
Name: mn-MN-YesuiNeural
|
741 |
+
Gender: Female
|
742 |
+
|
743 |
+
Name: mr-IN-AarohiNeural
|
744 |
+
Gender: Female
|
745 |
+
|
746 |
+
Name: mr-IN-ManoharNeural
|
747 |
+
Gender: Male
|
748 |
+
|
749 |
+
Name: ms-MY-OsmanNeural
|
750 |
+
Gender: Male
|
751 |
+
|
752 |
+
Name: ms-MY-YasminNeural
|
753 |
+
Gender: Female
|
754 |
+
|
755 |
+
Name: mt-MT-GraceNeural
|
756 |
+
Gender: Female
|
757 |
+
|
758 |
+
Name: mt-MT-JosephNeural
|
759 |
+
Gender: Male
|
760 |
+
|
761 |
+
Name: my-MM-NilarNeural
|
762 |
+
Gender: Female
|
763 |
+
|
764 |
+
Name: my-MM-ThihaNeural
|
765 |
+
Gender: Male
|
766 |
+
|
767 |
+
Name: nb-NO-FinnNeural
|
768 |
+
Gender: Male
|
769 |
+
|
770 |
+
Name: nb-NO-PernilleNeural
|
771 |
+
Gender: Female
|
772 |
+
|
773 |
+
Name: ne-NP-HemkalaNeural
|
774 |
+
Gender: Female
|
775 |
+
|
776 |
+
Name: ne-NP-SagarNeural
|
777 |
+
Gender: Male
|
778 |
+
|
779 |
+
Name: nl-BE-ArnaudNeural
|
780 |
+
Gender: Male
|
781 |
+
|
782 |
+
Name: nl-BE-DenaNeural
|
783 |
+
Gender: Female
|
784 |
+
|
785 |
+
Name: nl-NL-ColetteNeural
|
786 |
+
Gender: Female
|
787 |
+
|
788 |
+
Name: nl-NL-FennaNeural
|
789 |
+
Gender: Female
|
790 |
+
|
791 |
+
Name: nl-NL-MaartenNeural
|
792 |
+
Gender: Male
|
793 |
+
|
794 |
+
Name: pl-PL-MarekNeural
|
795 |
+
Gender: Male
|
796 |
+
|
797 |
+
Name: pl-PL-ZofiaNeural
|
798 |
+
Gender: Female
|
799 |
+
|
800 |
+
Name: ps-AF-GulNawazNeural
|
801 |
+
Gender: Male
|
802 |
+
|
803 |
+
Name: ps-AF-LatifaNeural
|
804 |
+
Gender: Female
|
805 |
+
|
806 |
+
Name: pt-BR-AntonioNeural
|
807 |
+
Gender: Male
|
808 |
+
|
809 |
+
Name: pt-BR-FranciscaNeural
|
810 |
+
Gender: Female
|
811 |
+
|
812 |
+
Name: pt-BR-ThalitaMultilingualNeural
|
813 |
+
Gender: Female
|
814 |
+
|
815 |
+
Name: pt-PT-DuarteNeural
|
816 |
+
Gender: Male
|
817 |
+
|
818 |
+
Name: pt-PT-RaquelNeural
|
819 |
+
Gender: Female
|
820 |
+
|
821 |
+
Name: ro-RO-AlinaNeural
|
822 |
+
Gender: Female
|
823 |
+
|
824 |
+
Name: ro-RO-EmilNeural
|
825 |
+
Gender: Male
|
826 |
+
|
827 |
+
Name: ru-RU-DmitryNeural
|
828 |
+
Gender: Male
|
829 |
+
|
830 |
+
Name: ru-RU-SvetlanaNeural
|
831 |
+
Gender: Female
|
832 |
+
|
833 |
+
Name: si-LK-SameeraNeural
|
834 |
+
Gender: Male
|
835 |
+
|
836 |
+
Name: si-LK-ThiliniNeural
|
837 |
+
Gender: Female
|
838 |
+
|
839 |
+
Name: sk-SK-LukasNeural
|
840 |
+
Gender: Male
|
841 |
+
|
842 |
+
Name: sk-SK-ViktoriaNeural
|
843 |
+
Gender: Female
|
844 |
+
|
845 |
+
Name: sl-SI-PetraNeural
|
846 |
+
Gender: Female
|
847 |
+
|
848 |
+
Name: sl-SI-RokNeural
|
849 |
+
Gender: Male
|
850 |
+
|
851 |
+
Name: so-SO-MuuseNeural
|
852 |
+
Gender: Male
|
853 |
+
|
854 |
+
Name: so-SO-UbaxNeural
|
855 |
+
Gender: Female
|
856 |
+
|
857 |
+
Name: sq-AL-AnilaNeural
|
858 |
+
Gender: Female
|
859 |
+
|
860 |
+
Name: sq-AL-IlirNeural
|
861 |
+
Gender: Male
|
862 |
+
|
863 |
+
Name: sr-RS-NicholasNeural
|
864 |
+
Gender: Male
|
865 |
+
|
866 |
+
Name: sr-RS-SophieNeural
|
867 |
+
Gender: Female
|
868 |
+
|
869 |
+
Name: su-ID-JajangNeural
|
870 |
+
Gender: Male
|
871 |
+
|
872 |
+
Name: su-ID-TutiNeural
|
873 |
+
Gender: Female
|
874 |
+
|
875 |
+
Name: sv-SE-MattiasNeural
|
876 |
+
Gender: Male
|
877 |
+
|
878 |
+
Name: sv-SE-SofieNeural
|
879 |
+
Gender: Female
|
880 |
+
|
881 |
+
Name: sw-KE-RafikiNeural
|
882 |
+
Gender: Male
|
883 |
+
|
884 |
+
Name: sw-KE-ZuriNeural
|
885 |
+
Gender: Female
|
886 |
+
|
887 |
+
Name: sw-TZ-DaudiNeural
|
888 |
+
Gender: Male
|
889 |
+
|
890 |
+
Name: sw-TZ-RehemaNeural
|
891 |
+
Gender: Female
|
892 |
+
|
893 |
+
Name: ta-IN-PallaviNeural
|
894 |
+
Gender: Female
|
895 |
+
|
896 |
+
Name: ta-IN-ValluvarNeural
|
897 |
+
Gender: Male
|
898 |
+
|
899 |
+
Name: ta-LK-KumarNeural
|
900 |
+
Gender: Male
|
901 |
+
|
902 |
+
Name: ta-LK-SaranyaNeural
|
903 |
+
Gender: Female
|
904 |
+
|
905 |
+
Name: ta-MY-KaniNeural
|
906 |
+
Gender: Female
|
907 |
+
|
908 |
+
Name: ta-MY-SuryaNeural
|
909 |
+
Gender: Male
|
910 |
+
|
911 |
+
Name: ta-SG-AnbuNeural
|
912 |
+
Gender: Male
|
913 |
+
|
914 |
+
Name: ta-SG-VenbaNeural
|
915 |
+
Gender: Female
|
916 |
+
|
917 |
+
Name: te-IN-MohanNeural
|
918 |
+
Gender: Male
|
919 |
+
|
920 |
+
Name: te-IN-ShrutiNeural
|
921 |
+
Gender: Female
|
922 |
+
|
923 |
+
Name: th-TH-NiwatNeural
|
924 |
+
Gender: Male
|
925 |
+
|
926 |
+
Name: th-TH-PremwadeeNeural
|
927 |
+
Gender: Female
|
928 |
+
|
929 |
+
Name: tr-TR-AhmetNeural
|
930 |
+
Gender: Male
|
931 |
+
|
932 |
+
Name: tr-TR-EmelNeural
|
933 |
+
Gender: Female
|
934 |
+
|
935 |
+
Name: uk-UA-OstapNeural
|
936 |
+
Gender: Male
|
937 |
+
|
938 |
+
Name: uk-UA-PolinaNeural
|
939 |
+
Gender: Female
|
940 |
+
|
941 |
+
Name: ur-IN-GulNeural
|
942 |
+
Gender: Female
|
943 |
+
|
944 |
+
Name: ur-IN-SalmanNeural
|
945 |
+
Gender: Male
|
946 |
+
|
947 |
+
Name: ur-PK-AsadNeural
|
948 |
+
Gender: Male
|
949 |
+
|
950 |
+
Name: ur-PK-UzmaNeural
|
951 |
+
Gender: Female
|
952 |
+
|
953 |
+
Name: uz-UZ-MadinaNeural
|
954 |
+
Gender: Female
|
955 |
+
|
956 |
+
Name: uz-UZ-SardorNeural
|
957 |
+
Gender: Male
|
958 |
+
|
959 |
+
Name: vi-VN-HoaiMyNeural
|
960 |
+
Gender: Female
|
961 |
+
|
962 |
+
Name: vi-VN-NamMinhNeural
|
963 |
+
Gender: Male
|
964 |
+
|
965 |
+
Name: zh-CN-XiaoxiaoNeural
|
966 |
+
Gender: Female
|
967 |
+
|
968 |
+
Name: zh-CN-XiaoyiNeural
|
969 |
+
Gender: Female
|
970 |
+
|
971 |
+
Name: zh-CN-YunjianNeural
|
972 |
+
Gender: Male
|
973 |
+
|
974 |
+
Name: zh-CN-YunxiNeural
|
975 |
+
Gender: Male
|
976 |
+
|
977 |
+
Name: zh-CN-YunxiaNeural
|
978 |
+
Gender: Male
|
979 |
+
|
980 |
+
Name: zh-CN-YunyangNeural
|
981 |
+
Gender: Male
|
982 |
+
|
983 |
+
Name: zh-CN-liaoning-XiaobeiNeural
|
984 |
+
Gender: Female
|
985 |
+
|
986 |
+
Name: zh-CN-shaanxi-XiaoniNeural
|
987 |
+
Gender: Female
|
988 |
+
|
989 |
+
Name: zh-HK-HiuGaaiNeural
|
990 |
+
Gender: Female
|
991 |
+
|
992 |
+
Name: zh-HK-HiuMaanNeural
|
993 |
+
Gender: Female
|
994 |
+
|
995 |
+
Name: zh-HK-WanLungNeural
|
996 |
+
Gender: Male
|
997 |
+
|
998 |
+
Name: zh-TW-HsiaoChenNeural
|
999 |
+
Gender: Female
|
1000 |
+
|
1001 |
+
Name: zh-TW-HsiaoYuNeural
|
1002 |
+
Gender: Female
|
1003 |
+
|
1004 |
+
Name: zh-TW-YunJheNeural
|
1005 |
+
Gender: Male
|
1006 |
+
|
1007 |
+
Name: zu-ZA-ThandoNeural
|
1008 |
+
Gender: Female
|
1009 |
+
|
1010 |
+
Name: zu-ZA-ThembaNeural
|
1011 |
+
Gender: Male
|
1012 |
+
|
1013 |
+
|
1014 |
+
Name: en-US-AvaMultilingualNeural-V2
|
1015 |
+
Gender: Female
|
1016 |
+
|
1017 |
+
Name: en-US-AndrewMultilingualNeural-V2
|
1018 |
+
Gender: Male
|
1019 |
+
|
1020 |
+
Name: en-US-EmmaMultilingualNeural-V2
|
1021 |
+
Gender: Female
|
1022 |
+
|
1023 |
+
Name: en-US-BrianMultilingualNeural-V2
|
1024 |
+
Gender: Male
|
1025 |
+
|
1026 |
+
Name: de-DE-FlorianMultilingualNeural-V2
|
1027 |
+
Gender: Male
|
1028 |
+
|
1029 |
+
Name: de-DE-SeraphinaMultilingualNeural-V2
|
1030 |
+
Gender: Female
|
1031 |
+
|
1032 |
+
Name: fr-FR-RemyMultilingualNeural-V2
|
1033 |
+
Gender: Male
|
1034 |
+
|
1035 |
+
Name: fr-FR-VivienneMultilingualNeural-V2
|
1036 |
+
Gender: Female
|
1037 |
+
|
1038 |
+
Name: zh-CN-XiaoxiaoMultilingualNeural-V2
|
1039 |
+
Gender: Female
|
1040 |
+
""".strip()
|
1041 |
+
voices = []
|
1042 |
+
# 定义正则表达式模式,用于匹配 Name 和 Gender 行
|
1043 |
+
pattern = re.compile(r"Name:\s*(.+)\s*Gender:\s*(.+)\s*", re.MULTILINE)
|
1044 |
+
# 使用正则表达式查找所有匹配项
|
1045 |
+
matches = pattern.findall(azure_voices_str)
|
1046 |
+
|
1047 |
+
for name, gender in matches:
|
1048 |
+
# 应用过滤条件
|
1049 |
+
if filter_locals and any(
|
1050 |
+
name.lower().startswith(fl.lower()) for fl in filter_locals
|
1051 |
+
):
|
1052 |
+
voices.append(f"{name}-{gender}")
|
1053 |
+
elif not filter_locals:
|
1054 |
+
voices.append(f"{name}-{gender}")
|
1055 |
+
|
1056 |
+
voices.sort()
|
1057 |
+
return voices
|
1058 |
+
|
1059 |
+
|
1060 |
+
def parse_voice_name(name: str):
|
1061 |
+
# zh-CN-XiaoyiNeural-Female
|
1062 |
+
# zh-CN-YunxiNeural-Male
|
1063 |
+
# zh-CN-XiaoxiaoMultilingualNeural-V2-Female
|
1064 |
+
name = name.replace("-Female", "").replace("-Male", "").strip()
|
1065 |
+
return name
|
1066 |
+
|
1067 |
+
|
1068 |
+
def is_azure_v2_voice(voice_name: str):
|
1069 |
+
voice_name = parse_voice_name(voice_name)
|
1070 |
+
if voice_name.endswith("-V2"):
|
1071 |
+
return voice_name.replace("-V2", "").strip()
|
1072 |
+
return ""
|
1073 |
+
|
1074 |
+
|
1075 |
+
def is_siliconflow_voice(voice_name: str):
|
1076 |
+
"""检查是否是硅基流动的声音"""
|
1077 |
+
return voice_name.startswith("siliconflow:")
|
1078 |
+
|
1079 |
+
|
1080 |
+
def tts(
|
1081 |
+
text: str,
|
1082 |
+
voice_name: str,
|
1083 |
+
voice_rate: float,
|
1084 |
+
voice_file: str,
|
1085 |
+
voice_volume: float = 1.0,
|
1086 |
+
) -> Union[SubMaker, None]:
|
1087 |
+
if is_azure_v2_voice(voice_name):
|
1088 |
+
return azure_tts_v2(text, voice_name, voice_file)
|
1089 |
+
elif is_siliconflow_voice(voice_name):
|
1090 |
+
# 从voice_name中提取模型和声音
|
1091 |
+
# 格式: siliconflow:model:voice-Gender
|
1092 |
+
parts = voice_name.split(":")
|
1093 |
+
if len(parts) >= 3:
|
1094 |
+
model = parts[1]
|
1095 |
+
# 移除性别后缀,例如 "alex-Male" -> "alex"
|
1096 |
+
voice_with_gender = parts[2]
|
1097 |
+
voice = voice_with_gender.split("-")[0]
|
1098 |
+
# 构建完整的voice参数,格式为 "model:voice"
|
1099 |
+
full_voice = f"{model}:{voice}"
|
1100 |
+
return siliconflow_tts(
|
1101 |
+
text, model, full_voice, voice_rate, voice_file, voice_volume
|
1102 |
+
)
|
1103 |
+
else:
|
1104 |
+
logger.error(f"Invalid siliconflow voice name format: {voice_name}")
|
1105 |
+
return None
|
1106 |
+
return azure_tts_v1(text, voice_name, voice_rate, voice_file)
|
1107 |
+
|
1108 |
+
|
1109 |
+
def convert_rate_to_percent(rate: float) -> str:
|
1110 |
+
if rate == 1.0:
|
1111 |
+
return "+0%"
|
1112 |
+
percent = round((rate - 1.0) * 100)
|
1113 |
+
if percent > 0:
|
1114 |
+
return f"+{percent}%"
|
1115 |
+
else:
|
1116 |
+
return f"{percent}%"
|
1117 |
+
|
1118 |
+
|
1119 |
+
def azure_tts_v1(
|
1120 |
+
text: str, voice_name: str, voice_rate: float, voice_file: str
|
1121 |
+
) -> Union[SubMaker, None]:
|
1122 |
+
voice_name = parse_voice_name(voice_name)
|
1123 |
+
text = text.strip()
|
1124 |
+
rate_str = convert_rate_to_percent(voice_rate)
|
1125 |
+
for i in range(3):
|
1126 |
+
try:
|
1127 |
+
logger.info(f"start, voice name: {voice_name}, try: {i + 1}")
|
1128 |
+
|
1129 |
+
async def _do() -> SubMaker:
|
1130 |
+
communicate = edge_tts.Communicate(text, voice_name, rate=rate_str)
|
1131 |
+
sub_maker = edge_tts.SubMaker()
|
1132 |
+
with open(voice_file, "wb") as file:
|
1133 |
+
async for chunk in communicate.stream():
|
1134 |
+
if chunk["type"] == "audio":
|
1135 |
+
file.write(chunk["data"])
|
1136 |
+
elif chunk["type"] == "WordBoundary":
|
1137 |
+
sub_maker.create_sub(
|
1138 |
+
(chunk["offset"], chunk["duration"]), chunk["text"]
|
1139 |
+
)
|
1140 |
+
return sub_maker
|
1141 |
+
|
1142 |
+
sub_maker = asyncio.run(_do())
|
1143 |
+
if not sub_maker or not sub_maker.subs:
|
1144 |
+
logger.warning("failed, sub_maker is None or sub_maker.subs is None")
|
1145 |
+
continue
|
1146 |
+
|
1147 |
+
logger.info(f"completed, output file: {voice_file}")
|
1148 |
+
return sub_maker
|
1149 |
+
except Exception as e:
|
1150 |
+
logger.error(f"failed, error: {str(e)}")
|
1151 |
+
return None
|
1152 |
+
|
1153 |
+
|
1154 |
+
def siliconflow_tts(
|
1155 |
+
text: str,
|
1156 |
+
model: str,
|
1157 |
+
voice: str,
|
1158 |
+
voice_rate: float,
|
1159 |
+
voice_file: str,
|
1160 |
+
voice_volume: float = 1.0,
|
1161 |
+
) -> Union[SubMaker, None]:
|
1162 |
+
"""
|
1163 |
+
使用硅基流动的API生成语音
|
1164 |
+
|
1165 |
+
Args:
|
1166 |
+
text: 要转换为语音的文本
|
1167 |
+
model: 模型名称,如 "FunAudioLLM/CosyVoice2-0.5B"
|
1168 |
+
voice: 声音名称,如 "FunAudioLLM/CosyVoice2-0.5B:alex"
|
1169 |
+
voice_rate: 语音速度,范围[0.25, 4.0]
|
1170 |
+
voice_file: 输出的音频文件路径
|
1171 |
+
voice_volume: 语音音量,范围[0.6, 5.0],需要转换为硅基流动的增益范围[-10, 10]
|
1172 |
+
|
1173 |
+
Returns:
|
1174 |
+
SubMaker对象或None
|
1175 |
+
"""
|
1176 |
+
text = text.strip()
|
1177 |
+
api_key = config.siliconflow.get("api_key", "")
|
1178 |
+
|
1179 |
+
if not api_key:
|
1180 |
+
logger.error("SiliconFlow API key is not set")
|
1181 |
+
return None
|
1182 |
+
|
1183 |
+
# 将voice_volume转换为硅基流动的增益范围
|
1184 |
+
# 默认voice_volume为1.0,对应gain为0
|
1185 |
+
gain = voice_volume - 1.0
|
1186 |
+
# 确保gain在[-10, 10]范围内
|
1187 |
+
gain = max(-10, min(10, gain))
|
1188 |
+
|
1189 |
+
url = "https://api.siliconflow.cn/v1/audio/speech"
|
1190 |
+
|
1191 |
+
payload = {
|
1192 |
+
"model": model,
|
1193 |
+
"input": text,
|
1194 |
+
"voice": voice,
|
1195 |
+
"response_format": "mp3",
|
1196 |
+
"sample_rate": 32000,
|
1197 |
+
"stream": False,
|
1198 |
+
"speed": voice_rate,
|
1199 |
+
"gain": gain,
|
1200 |
+
}
|
1201 |
+
|
1202 |
+
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
|
1203 |
+
|
1204 |
+
for i in range(3): # 尝试3次
|
1205 |
+
try:
|
1206 |
+
logger.info(
|
1207 |
+
f"start siliconflow tts, model: {model}, voice: {voice}, try: {i + 1}"
|
1208 |
+
)
|
1209 |
+
|
1210 |
+
response = requests.post(url, json=payload, headers=headers)
|
1211 |
+
|
1212 |
+
if response.status_code == 200:
|
1213 |
+
# 保存音频文件
|
1214 |
+
with open(voice_file, "wb") as f:
|
1215 |
+
f.write(response.content)
|
1216 |
+
|
1217 |
+
# 创建一个空的SubMaker对象
|
1218 |
+
sub_maker = SubMaker()
|
1219 |
+
|
1220 |
+
# 获取音频文件的实际长度
|
1221 |
+
try:
|
1222 |
+
# 尝试使用moviepy获取音频长度
|
1223 |
+
from moviepy import AudioFileClip
|
1224 |
+
|
1225 |
+
audio_clip = AudioFileClip(voice_file)
|
1226 |
+
audio_duration = audio_clip.duration
|
1227 |
+
audio_clip.close()
|
1228 |
+
|
1229 |
+
# 将音频长度转换为100纳秒单位(与edge_tts兼容)
|
1230 |
+
audio_duration_100ns = int(audio_duration * 10000000)
|
1231 |
+
|
1232 |
+
# 使用文本分割来创建更准确的字幕
|
1233 |
+
# 将文本按标点符号分割成句子
|
1234 |
+
sentences = utils.split_string_by_punctuations(text)
|
1235 |
+
|
1236 |
+
if sentences:
|
1237 |
+
# 计算每个句子的大致时长(按字符数比例分配)
|
1238 |
+
total_chars = sum(len(s) for s in sentences)
|
1239 |
+
char_duration = (
|
1240 |
+
audio_duration_100ns / total_chars if total_chars > 0 else 0
|
1241 |
+
)
|
1242 |
+
|
1243 |
+
current_offset = 0
|
1244 |
+
for sentence in sentences:
|
1245 |
+
if not sentence.strip():
|
1246 |
+
continue
|
1247 |
+
|
1248 |
+
# 计算当前句子的时长
|
1249 |
+
sentence_chars = len(sentence)
|
1250 |
+
sentence_duration = int(sentence_chars * char_duration)
|
1251 |
+
|
1252 |
+
# 添加到SubMaker
|
1253 |
+
sub_maker.subs.append(sentence)
|
1254 |
+
sub_maker.offset.append(
|
1255 |
+
(current_offset, current_offset + sentence_duration)
|
1256 |
+
)
|
1257 |
+
|
1258 |
+
# 更新偏移量
|
1259 |
+
current_offset += sentence_duration
|
1260 |
+
else:
|
1261 |
+
# 如果无法分割,则使用整个文本作为一个字幕
|
1262 |
+
sub_maker.subs = [text]
|
1263 |
+
sub_maker.offset = [(0, audio_duration_100ns)]
|
1264 |
+
|
1265 |
+
except Exception as e:
|
1266 |
+
logger.warning(f"Failed to create accurate subtitles: {str(e)}")
|
1267 |
+
# 回退到简单的字幕
|
1268 |
+
sub_maker.subs = [text]
|
1269 |
+
# 使用音频文件的实际长度,如果无法获取,则假设为10秒
|
1270 |
+
sub_maker.offset = [
|
1271 |
+
(
|
1272 |
+
0,
|
1273 |
+
audio_duration_100ns
|
1274 |
+
if "audio_duration_100ns" in locals()
|
1275 |
+
else 10000000,
|
1276 |
+
)
|
1277 |
+
]
|
1278 |
+
|
1279 |
+
logger.success(f"siliconflow tts succeeded: {voice_file}")
|
1280 |
+
print("s", sub_maker.subs, sub_maker.offset)
|
1281 |
+
return sub_maker
|
1282 |
+
else:
|
1283 |
+
logger.error(
|
1284 |
+
f"siliconflow tts failed with status code {response.status_code}: {response.text}"
|
1285 |
+
)
|
1286 |
+
except Exception as e:
|
1287 |
+
logger.error(f"siliconflow tts failed: {str(e)}")
|
1288 |
+
|
1289 |
+
return None
|
1290 |
+
|
1291 |
+
|
1292 |
+
def azure_tts_v2(text: str, voice_name: str, voice_file: str) -> Union[SubMaker, None]:
|
1293 |
+
voice_name = is_azure_v2_voice(voice_name)
|
1294 |
+
if not voice_name:
|
1295 |
+
logger.error(f"invalid voice name: {voice_name}")
|
1296 |
+
raise ValueError(f"invalid voice name: {voice_name}")
|
1297 |
+
text = text.strip()
|
1298 |
+
|
1299 |
+
def _format_duration_to_offset(duration) -> int:
|
1300 |
+
if isinstance(duration, str):
|
1301 |
+
time_obj = datetime.strptime(duration, "%H:%M:%S.%f")
|
1302 |
+
milliseconds = (
|
1303 |
+
(time_obj.hour * 3600000)
|
1304 |
+
+ (time_obj.minute * 60000)
|
1305 |
+
+ (time_obj.second * 1000)
|
1306 |
+
+ (time_obj.microsecond // 1000)
|
1307 |
+
)
|
1308 |
+
return milliseconds * 10000
|
1309 |
+
|
1310 |
+
if isinstance(duration, int):
|
1311 |
+
return duration
|
1312 |
+
|
1313 |
+
return 0
|
1314 |
+
|
1315 |
+
for i in range(3):
|
1316 |
+
try:
|
1317 |
+
logger.info(f"start, voice name: {voice_name}, try: {i + 1}")
|
1318 |
+
|
1319 |
+
import azure.cognitiveservices.speech as speechsdk
|
1320 |
+
|
1321 |
+
sub_maker = SubMaker()
|
1322 |
+
|
1323 |
+
def speech_synthesizer_word_boundary_cb(evt: speechsdk.SessionEventArgs):
|
1324 |
+
# print('WordBoundary event:')
|
1325 |
+
# print('\tBoundaryType: {}'.format(evt.boundary_type))
|
1326 |
+
# print('\tAudioOffset: {}ms'.format((evt.audio_offset + 5000)))
|
1327 |
+
# print('\tDuration: {}'.format(evt.duration))
|
1328 |
+
# print('\tText: {}'.format(evt.text))
|
1329 |
+
# print('\tTextOffset: {}'.format(evt.text_offset))
|
1330 |
+
# print('\tWordLength: {}'.format(evt.word_length))
|
1331 |
+
|
1332 |
+
duration = _format_duration_to_offset(str(evt.duration))
|
1333 |
+
offset = _format_duration_to_offset(evt.audio_offset)
|
1334 |
+
sub_maker.subs.append(evt.text)
|
1335 |
+
sub_maker.offset.append((offset, offset + duration))
|
1336 |
+
|
1337 |
+
# Creates an instance of a speech config with specified subscription key and service region.
|
1338 |
+
speech_key = config.azure.get("speech_key", "")
|
1339 |
+
service_region = config.azure.get("speech_region", "")
|
1340 |
+
if not speech_key or not service_region:
|
1341 |
+
logger.error("Azure speech key or region is not set")
|
1342 |
+
return None
|
1343 |
+
|
1344 |
+
audio_config = speechsdk.audio.AudioOutputConfig(
|
1345 |
+
filename=voice_file, use_default_speaker=True
|
1346 |
+
)
|
1347 |
+
speech_config = speechsdk.SpeechConfig(
|
1348 |
+
subscription=speech_key, region=service_region
|
1349 |
+
)
|
1350 |
+
speech_config.speech_synthesis_voice_name = voice_name
|
1351 |
+
# speech_config.set_property(property_id=speechsdk.PropertyId.SpeechServiceResponse_RequestSentenceBoundary,
|
1352 |
+
# value='true')
|
1353 |
+
speech_config.set_property(
|
1354 |
+
property_id=speechsdk.PropertyId.SpeechServiceResponse_RequestWordBoundary,
|
1355 |
+
value="true",
|
1356 |
+
)
|
1357 |
+
|
1358 |
+
speech_config.set_speech_synthesis_output_format(
|
1359 |
+
speechsdk.SpeechSynthesisOutputFormat.Audio48Khz192KBitRateMonoMp3
|
1360 |
+
)
|
1361 |
+
speech_synthesizer = speechsdk.SpeechSynthesizer(
|
1362 |
+
audio_config=audio_config, speech_config=speech_config
|
1363 |
+
)
|
1364 |
+
speech_synthesizer.synthesis_word_boundary.connect(
|
1365 |
+
speech_synthesizer_word_boundary_cb
|
1366 |
+
)
|
1367 |
+
|
1368 |
+
result = speech_synthesizer.speak_text_async(text).get()
|
1369 |
+
if result.reason == speechsdk.ResultReason.SynthesizingAudioCompleted:
|
1370 |
+
logger.success(f"azure v2 speech synthesis succeeded: {voice_file}")
|
1371 |
+
return sub_maker
|
1372 |
+
elif result.reason == speechsdk.ResultReason.Canceled:
|
1373 |
+
cancellation_details = result.cancellation_details
|
1374 |
+
logger.error(
|
1375 |
+
f"azure v2 speech synthesis canceled: {cancellation_details.reason}"
|
1376 |
+
)
|
1377 |
+
if cancellation_details.reason == speechsdk.CancellationReason.Error:
|
1378 |
+
logger.error(
|
1379 |
+
f"azure v2 speech synthesis error: {cancellation_details.error_details}"
|
1380 |
+
)
|
1381 |
+
logger.info(f"completed, output file: {voice_file}")
|
1382 |
+
except Exception as e:
|
1383 |
+
logger.error(f"failed, error: {str(e)}")
|
1384 |
+
return None
|
1385 |
+
|
1386 |
+
|
1387 |
+
def _format_text(text: str) -> str:
|
1388 |
+
# text = text.replace("\n", " ")
|
1389 |
+
text = text.replace("[", " ")
|
1390 |
+
text = text.replace("]", " ")
|
1391 |
+
text = text.replace("(", " ")
|
1392 |
+
text = text.replace(")", " ")
|
1393 |
+
text = text.replace("{", " ")
|
1394 |
+
text = text.replace("}", " ")
|
1395 |
+
text = text.strip()
|
1396 |
+
return text
|
1397 |
+
|
1398 |
+
|
1399 |
+
def create_subtitle(sub_maker: submaker.SubMaker, text: str, subtitle_file: str):
|
1400 |
+
"""
|
1401 |
+
优化字幕文件
|
1402 |
+
1. 将字幕文件按照标点符号分割成多行
|
1403 |
+
2. 逐行匹配字幕文件中的文本
|
1404 |
+
3. 生成新的字幕文件
|
1405 |
+
"""
|
1406 |
+
|
1407 |
+
text = _format_text(text)
|
1408 |
+
|
1409 |
+
def formatter(idx: int, start_time: float, end_time: float, sub_text: str) -> str:
|
1410 |
+
"""
|
1411 |
+
1
|
1412 |
+
00:00:00,000 --> 00:00:02,360
|
1413 |
+
跑步是一项简单易行的运动
|
1414 |
+
"""
|
1415 |
+
start_t = mktimestamp(start_time).replace(".", ",")
|
1416 |
+
end_t = mktimestamp(end_time).replace(".", ",")
|
1417 |
+
return f"{idx}\n{start_t} --> {end_t}\n{sub_text}\n"
|
1418 |
+
|
1419 |
+
start_time = -1.0
|
1420 |
+
sub_items = []
|
1421 |
+
sub_index = 0
|
1422 |
+
|
1423 |
+
script_lines = utils.split_string_by_punctuations(text)
|
1424 |
+
|
1425 |
+
def match_line(_sub_line: str, _sub_index: int):
|
1426 |
+
if len(script_lines) <= _sub_index:
|
1427 |
+
return ""
|
1428 |
+
|
1429 |
+
_line = script_lines[_sub_index]
|
1430 |
+
if _sub_line == _line:
|
1431 |
+
return script_lines[_sub_index].strip()
|
1432 |
+
|
1433 |
+
_sub_line_ = re.sub(r"[^\w\s]", "", _sub_line)
|
1434 |
+
_line_ = re.sub(r"[^\w\s]", "", _line)
|
1435 |
+
if _sub_line_ == _line_:
|
1436 |
+
return _line_.strip()
|
1437 |
+
|
1438 |
+
_sub_line_ = re.sub(r"\W+", "", _sub_line)
|
1439 |
+
_line_ = re.sub(r"\W+", "", _line)
|
1440 |
+
if _sub_line_ == _line_:
|
1441 |
+
return _line.strip()
|
1442 |
+
|
1443 |
+
return ""
|
1444 |
+
|
1445 |
+
sub_line = ""
|
1446 |
+
|
1447 |
+
try:
|
1448 |
+
for _, (offset, sub) in enumerate(zip(sub_maker.offset, sub_maker.subs)):
|
1449 |
+
_start_time, end_time = offset
|
1450 |
+
if start_time < 0:
|
1451 |
+
start_time = _start_time
|
1452 |
+
|
1453 |
+
sub = unescape(sub)
|
1454 |
+
sub_line += sub
|
1455 |
+
sub_text = match_line(sub_line, sub_index)
|
1456 |
+
if sub_text:
|
1457 |
+
sub_index += 1
|
1458 |
+
line = formatter(
|
1459 |
+
idx=sub_index,
|
1460 |
+
start_time=start_time,
|
1461 |
+
end_time=end_time,
|
1462 |
+
sub_text=sub_text,
|
1463 |
+
)
|
1464 |
+
sub_items.append(line)
|
1465 |
+
start_time = -1.0
|
1466 |
+
sub_line = ""
|
1467 |
+
|
1468 |
+
if len(sub_items) == len(script_lines):
|
1469 |
+
with open(subtitle_file, "w", encoding="utf-8") as file:
|
1470 |
+
file.write("\n".join(sub_items) + "\n")
|
1471 |
+
try:
|
1472 |
+
sbs = subtitles.file_to_subtitles(subtitle_file, encoding="utf-8")
|
1473 |
+
duration = max([tb for ((ta, tb), txt) in sbs])
|
1474 |
+
logger.info(
|
1475 |
+
f"completed, subtitle file created: {subtitle_file}, duration: {duration}"
|
1476 |
+
)
|
1477 |
+
except Exception as e:
|
1478 |
+
logger.error(f"failed, error: {str(e)}")
|
1479 |
+
os.remove(subtitle_file)
|
1480 |
+
else:
|
1481 |
+
logger.warning(
|
1482 |
+
f"failed, sub_items len: {len(sub_items)}, script_lines len: {len(script_lines)}"
|
1483 |
+
)
|
1484 |
+
|
1485 |
+
except Exception as e:
|
1486 |
+
logger.error(f"failed, error: {str(e)}")
|
1487 |
+
|
1488 |
+
|
1489 |
+
def get_audio_duration(sub_maker: submaker.SubMaker):
|
1490 |
+
"""
|
1491 |
+
获取音频时长
|
1492 |
+
"""
|
1493 |
+
if not sub_maker.offset:
|
1494 |
+
return 0.0
|
1495 |
+
return sub_maker.offset[-1][1] / 10000000
|
1496 |
+
|
1497 |
+
|
1498 |
+
if __name__ == "__main__":
|
1499 |
+
voice_name = "zh-CN-XiaoxiaoMultilingualNeural-V2-Female"
|
1500 |
+
voice_name = parse_voice_name(voice_name)
|
1501 |
+
voice_name = is_azure_v2_voice(voice_name)
|
1502 |
+
print(voice_name)
|
1503 |
+
|
1504 |
+
voices = get_all_azure_voices()
|
1505 |
+
print(len(voices))
|
1506 |
+
|
1507 |
+
async def _do():
|
1508 |
+
temp_dir = utils.storage_dir("temp")
|
1509 |
+
|
1510 |
+
voice_names = [
|
1511 |
+
"zh-CN-XiaoxiaoMultilingualNeural",
|
1512 |
+
# 女性
|
1513 |
+
"zh-CN-XiaoxiaoNeural",
|
1514 |
+
"zh-CN-XiaoyiNeural",
|
1515 |
+
# 男性
|
1516 |
+
"zh-CN-YunyangNeural",
|
1517 |
+
"zh-CN-YunxiNeural",
|
1518 |
+
]
|
1519 |
+
text = """
|
1520 |
+
静夜思是唐代诗人李白创作的一首五言古诗。这首诗描绘了诗人在寂静的夜晚,看到窗前的明月,不禁想起远方的家乡和亲人,表达了他对家乡和亲人的深深思念之情。全诗内容是:“床前明月光,疑是地上霜。举头望明月,低头思故乡。”在这短短的四句诗中,诗人通过“明月”和“思故乡”的意象,巧妙地表达了离乡背井人的孤独与哀愁。首句“床前明月光”设景立意,通过明亮的月光引出诗人的遐想;“疑是地上霜”增添了夜晚的寒冷感,加深了诗人的孤寂之情;“举头望明月”和“低头思故乡”则是情感的升华,展现了诗人内心深处的乡愁和对家的渴望。这首诗简洁明快,情感真挚,是中国古典诗歌中非常著名的一首,也深受后人喜爱和推崇。
|
1521 |
+
"""
|
1522 |
+
|
1523 |
+
text = """
|
1524 |
+
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.
|
1525 |
+
"""
|
1526 |
+
|
1527 |
+
text = """
|
1528 |
+
预计未来3天深圳冷空气活动频繁,未来两天持续阴天有小雨,出门带好雨具;
|
1529 |
+
10-11日持续阴天有小雨,日温差小,气温在13-17℃之间,体感阴凉;
|
1530 |
+
12日天气短暂好转,早晚清凉;
|
1531 |
+
"""
|
1532 |
+
|
1533 |
+
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.]"
|
1534 |
+
|
1535 |
+
text = "大家好,我是乔哥,一个想帮你把信用卡全部还清的家伙!\n今天我们要聊的是信用卡的取现功能。\n你是不是也曾经因为一时的资金紧张,而拿着信用卡到ATM机取现?如果是,那你得好好看看这个视频了。\n现在都2024年了,我以为现在不会再有人用信用卡取现功能了。前几天一个粉丝发来一张图片,取现1万。\n信用卡取现有三个弊端。\n一,信用卡取现功能代价可不小。会先收取一个取现手续费,比如这个粉丝,取现1万,按2.5%收取手续费,收取了250元。\n二,信用卡正常消费有最长56天的免息期,但取现不享受免息期。从取现那一天开始,每天按照万5收取利息,这个粉丝用了11天,收取了55元利息。\n三,频繁的取现行为,银行会认为你资金紧张,会被标记为高风险用户,影响你的综合评分和额度。\n那么,如果你资金紧张了,该怎么办呢?\n乔哥给你支一招,用破思机摩擦信用卡,只需要少量的手续费,而且还可以享受最长56天的免息期。\n最后,如果你对玩卡感兴趣,可以找乔哥领取一本《卡神秘籍》,用卡过程中遇到任何疑惑,也欢迎找乔哥交流。\n别忘了,关注乔哥,回复用卡技巧,免费领取《2024用卡技巧》,让我们一起成为用卡高手!"
|
1536 |
+
|
1537 |
+
text = """
|
1538 |
+
2023全年业绩速览
|
1539 |
+
公司全年累计实现营业收入1476.94亿元,同比增长19.01%,归母净利润747.34亿元,同比增长19.16%。EPS达到59.49元。第四季度单季,营业收入444.25亿元,同比增长20.26%,环比增长31.86%;归母净利润218.58亿元,同比增长19.33%,环比增长29.37%。这一阶段
|
1540 |
+
的业绩表现不仅突显了公司的增长动力和盈利能力,也反映出公司在竞争激烈的市场环境中保持了良好的发展势头。
|
1541 |
+
2023年Q4业绩速览
|
1542 |
+
第四季度,营业收入贡献主要增长点;销售费用高增致盈利能力承压;税金同比上升27%,扰动净利率表现。
|
1543 |
+
业绩解读
|
1544 |
+
利润方面,2023全年贵州茅台,>归母净利润增速为19%,其中营业收入正贡献18%,营业成本正贡献百分之一,管理费用正贡献百分之一点四。(注:归母净利润增速值=营业收入增速+各科目贡献,展示贡献/拖累的前四名科目,且要求贡献值/净利润增速>15%)
|
1545 |
+
"""
|
1546 |
+
text = "静夜思是唐代诗人李白创作的一首五言古诗。这首诗描绘了诗人在寂静的夜晚,看到窗前的明月,不禁想起远方的家乡和亲人"
|
1547 |
+
|
1548 |
+
text = _format_text(text)
|
1549 |
+
lines = utils.split_string_by_punctuations(text)
|
1550 |
+
print(lines)
|
1551 |
+
|
1552 |
+
for voice_name in voice_names:
|
1553 |
+
voice_file = f"{temp_dir}/tts-{voice_name}.mp3"
|
1554 |
+
subtitle_file = f"{temp_dir}/tts.mp3.srt"
|
1555 |
+
sub_maker = azure_tts_v2(
|
1556 |
+
text=text, voice_name=voice_name, voice_file=voice_file
|
1557 |
+
)
|
1558 |
+
create_subtitle(sub_maker=sub_maker, text=text, subtitle_file=subtitle_file)
|
1559 |
+
audio_duration = get_audio_duration(sub_maker)
|
1560 |
+
print(f"voice: {voice_name}, audio duration: {audio_duration}s")
|
1561 |
+
|
1562 |
+
loop = asyncio.get_event_loop_policy().get_event_loop()
|
1563 |
+
try:
|
1564 |
+
loop.run_until_complete(_do())
|
1565 |
+
finally:
|
1566 |
+
loop.close()
|
app/utils/utils.py
ADDED
@@ -0,0 +1,230 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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('.')
|
config.example.toml
ADDED
@@ -0,0 +1,214 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|
docker-compose.yml
ADDED
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
x-common-volumes: &common-volumes
|
2 |
+
- ./:/MoneyPrinterTurbo
|
3 |
+
|
4 |
+
services:
|
5 |
+
webui:
|
6 |
+
build:
|
7 |
+
context: .
|
8 |
+
dockerfile: Dockerfile
|
9 |
+
container_name: "moneyprinterturbo-webui"
|
10 |
+
ports:
|
11 |
+
- "8501:8501"
|
12 |
+
command: [ "streamlit", "run", "./webui/Main.py","--browser.serverAddress=127.0.0.1","--server.enableCORS=True","--browser.gatherUsageStats=False" ]
|
13 |
+
volumes: *common-volumes
|
14 |
+
restart: always
|
15 |
+
api:
|
16 |
+
build:
|
17 |
+
context: .
|
18 |
+
dockerfile: Dockerfile
|
19 |
+
container_name: "moneyprinterturbo-api"
|
20 |
+
ports:
|
21 |
+
- "8080:8080"
|
22 |
+
command: [ "python3", "main.py" ]
|
23 |
+
volumes: *common-volumes
|
24 |
+
restart: always
|
docs/MoneyPrinterTurbo.ipynb
ADDED
@@ -0,0 +1,118 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 |
+
}
|
docs/api.jpg
ADDED
![]() |
Git LFS Details
|
docs/picwish.com.jpg
ADDED
![]() |
Git LFS Details
|
docs/picwish.jpg
ADDED
![]() |
Git LFS Details
|
docs/reccloud.cn.jpg
ADDED
![]() |
Git LFS Details
|
docs/reccloud.com.jpg
ADDED
![]() |
Git LFS Details
|
docs/voice-list.txt
ADDED
@@ -0,0 +1,941 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|
docs/webui-en.jpg
ADDED
![]() |
Git LFS Details
|
docs/webui.jpg
ADDED
![]() |
Git LFS Details
|
main.py
ADDED
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import uvicorn
|
2 |
+
from loguru import logger
|
3 |
+
|
4 |
+
from app.config import config
|
5 |
+
|
6 |
+
if __name__ == "__main__":
|
7 |
+
logger.info(
|
8 |
+
"start server, docs: http://127.0.0.1:" + str(config.listen_port) + "/docs"
|
9 |
+
)
|
10 |
+
uvicorn.run(
|
11 |
+
app="app.asgi:app",
|
12 |
+
host=config.listen_host,
|
13 |
+
port=config.listen_port,
|
14 |
+
reload=config.reload_debug,
|
15 |
+
log_level="warning",
|
16 |
+
)
|
requirements.txt
ADDED
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|
resource/fonts/Charm-Bold.ttf
ADDED
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
1 |
+
version https://git-lfs.github.com/spec/v1
|
2 |
+
oid sha256:e7b614c116724be24140c25f71a218be04cd7f0c32c33423aa0571963a0027eb
|
3 |
+
size 135332
|
resource/fonts/Charm-Regular.ttf
ADDED
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
1 |
+
version https://git-lfs.github.com/spec/v1
|
2 |
+
oid sha256:e3df8c7ae07f5d8cde91b4a033f4d281c0b9f3014f00c53644a11907b0ad08f6
|
3 |
+
size 134560
|
resource/fonts/MicrosoftYaHeiBold.ttc
ADDED
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
1 |
+
version https://git-lfs.github.com/spec/v1
|
2 |
+
oid sha256:519309b7ab0479c4dc3ace5e291de5a8702175be5586e165bc810267bd4619a5
|
3 |
+
size 16880832
|
resource/fonts/MicrosoftYaHeiNormal.ttc
ADDED
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
1 |
+
version https://git-lfs.github.com/spec/v1
|
2 |
+
oid sha256:3084f1f88369af6bf9989c909024164d953d1e38d08734f05f28ef24b2f9d577
|
3 |
+
size 19701556
|
resource/fonts/STHeitiLight.ttc
ADDED
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
1 |
+
version https://git-lfs.github.com/spec/v1
|
2 |
+
oid sha256:a57b0316cc0544f682b8fb9855e14ade79ae77340ef6a01ba9210e25b4c5a5b7
|
3 |
+
size 55783456
|