277 Commits

Author SHA1 Message Date
Harry
c03dc9c984 Merge pull request #652 from harry0703/dev
perf: optimize memory usage and processing performance, bump version to 1.2.5
2025-05-09 20:56:14 +08:00
harry
7569c08a62 perf: bump version to 1.2.5 2025-05-09 20:55:36 +08:00
harry
f07e5802f7 perf: optimize memory usage and processing performance 2025-05-09 20:55:12 +08:00
Harry
ffcfe8e03b Merge pull request #642 from harry0703/dev
feat: remove voice filter
2025-05-08 18:10:16 +08:00
harry
35a7ef657a feat: remove voice filter 2025-05-08 18:09:26 +08:00
Harry
250ec4f65c Merge pull request #641 from harry0703/dev
update
2025-05-08 17:39:44 +08:00
harry
5d0ffdad8a feat: update README.md for clarity and remove outdated information 2025-05-08 17:39:16 +08:00
harry
95e4d3170d feat: rename container names in docker-compose.yml 2025-05-08 17:35:12 +08:00
harry
dfa8328bb0 feat: optimize code 2025-05-08 17:34:51 +08:00
harry
5177c1871a feat: comment out interline and size parameters in video.py 2025-05-08 17:34:09 +08:00
Harry
1901c2905b Merge pull request #639 from harry0703/dev
feat: remove streamlit_authenticator
2025-05-08 15:53:06 +08:00
harry
b312c52a33 feat: remove streamlit_authenticator 2025-05-08 15:51:33 +08:00
Harry
fb974cefcf Merge pull request #638 from harry0703/dev
bump version to 1.2.4
2025-05-08 15:45:00 +08:00
harry
c7f7fa12b4 feat: optimize code and bump version to 1.2.4 2025-05-08 15:44:07 +08:00
harry
6a19e2bb29 feat: update requirements.txt and config.example.toml 2025-05-08 15:40:46 +08:00
Harry
443f5bf61e Merge pull request #632 from eren1106/fix-subtitle-bug
Fix subtitle generation not working by setting the default subtitle provider to "edge"
2025-05-08 09:10:19 +08:00
Harry
7d00e9c768 Merge pull request #617 from garylab/main
Solve subtitle header and footer was cut in some font family
2025-05-08 09:09:45 +08:00
Harry
c0ab0ba473 Merge pull request #614 from faycal-rakza/fix/comment
fix(dockerfile): comment fix
2025-05-08 09:08:55 +08:00
Gary Meng
4b2f9e42d7 Merge branch 'harry0703:main' into main 2025-05-07 11:28:57 +04:00
eren
4ce32a8851 fix: set default subtitle provider to 'edge' 2025-05-01 14:35:23 +08:00
yyhhyyyyyy
47e4cff758 feat: Add PDM support with auth & i18n enhancements (#627)
* feat: Add PDM support with auth & i18n enhancements

1. Added PDM project dependency management
   - Created pyproject.toml for dependency definitions
   - Added PDM lock file for reproducible builds
   - Created .pdm-python for virtual environment management

2. Enhanced authentication & configuration
   - Added user validation in base configuration
   - Implemented streamlit-authenticator for login functionality
   - Updated config.example.toml with user authentication fields

3. Improved internationalization (i18n)
   - Updated translation files for multiple languages (en, de, pt, vi, zh)
   - Enhanced i18n support in the web UI
   - Standardized translation structure across language files
2025-04-27 13:35:45 +08:00
Gary Meng
96e109e199 Solve subtitle header and footer was cut in some font family 2025-03-26 20:57:13 +04:00
Harry
36dffe8de3 Merge pull request #599 from bz-e/main
refactor: Refactor the get_all_azure_voices function
2025-03-23 18:45:26 +08:00
Harry
6d2e4a8081 Merge pull request #603 from garymengcom/main
Add get_all_tasks() endpoint and update .gitignore
2025-03-23 18:40:52 +08:00
faycal
a7c45b125f fix(dockerfile): comment fix 2025-03-09 00:23:55 +01:00
Guozao Meng
6c2b5b8cf4 Update .gitignore 2025-03-08 22:54:10 +04:00
Guozao Meng
91e9f3900d Add get_all_tasks() endpoint 2025-03-08 22:53:22 +04:00
evan.zhang5
ab1bd03f0b refactor: Refactor the get_all_azure_voices function to reduce the amount of code by half 2025-02-27 17:31:32 +08:00
Harry
cd0cbc8061 Merge pull request #583 from iorikingdom/main
Update requirements.txt
2025-02-10 11:08:23 +08:00
iorikingdom
c6c6390a83 Update requirements.txt 2025-02-09 02:26:43 +09:00
iorikingdom
6bfb9355cf Update requirements.txt 2025-02-09 02:20:21 +09:00
harry
34d785a246 feat: remove wechat qrcode 2025-02-07 17:07:06 +08:00
harry
c9bd480514 fix: ModuleNotFoundError: No module named 'app' 2025-02-07 17:06:26 +08:00
Harry
5349f29415 Merge pull request #579 from vipinbihari/patch-1
Update video.py - Fixing BackGround Music Volume Multiplier
2025-02-05 14:53:04 +08:00
VIPIN BIHARI
6500cafa4f Update video.py - Fixing BackGround Music Volume Multiplier
These was a typo in MuiliplyVolume function parameter. The name of the parameter should be bgm_voice
2025-01-29 21:08:17 +05:30
yyhhyy
e2e92a433e feat: Add video transition effects (fadein, fadeout, slidein, slideout) 2025-01-23 12:13:04 +08:00
yyhhyyyyyy
dd90cfecbb feat: Added SlideIn and SlideOut video transition effects and optimized front-end implementation 2025-01-09 19:46:57 +08:00
yyhhyyyyyy
7a5b037ad8 feat: Add video transition effects (fadein, fadeout) 2024-12-24 22:39:48 +08:00
Harry
ee0d2371d5 Merge pull request #554 from yyhhyyyyyy/llm-logic
🐛 fix: fix the LLM logic
2024-12-12 16:54:09 +08:00
yyhhyyyyyy
c4586d37f5 🎨 style: format llm.py code 2024-12-12 14:32:17 +08:00
yyhhyyyyyy
2d8cd23fe7 🐛 fix: fix the LLM logic 2024-12-12 14:29:14 +08:00
Harry
85d446e2d0 Merge pull request #552 from yyhhyyyyyy/code-cleanup
🎨 style: Format Code
2024-12-10 14:45:11 +08:00
yyhhyyyyyy
afd064e15d 🎨 style: Format Code 2024-12-10 10:34:56 +08:00
Harry
809d6cabbb Merge pull request #548 from harry0703/dev
feat: add feature request template
2024-12-06 15:48:01 +08:00
harry
8058eed9ab feat: add feature request template 2024-12-06 15:47:04 +08:00
Harry
15ee6126a5 Merge pull request #547 from harry0703/dev
feat: add issue template
2024-12-06 15:37:45 +08:00
harry
b6a7ea2756 feat: add issue template 2024-12-06 15:37:23 +08:00
Harry
63c3402c94 Update version to 1.2.2 2024-12-06 13:45:43 +08:00
Harry
5a6dd6c7a5 Merge pull request #541 from yyhhyyyyyy/update-requirements
⬆️ deps: Upgrade dependencies to latest versions and address minor issues
2024-12-05 11:02:14 +08:00
yyhhyy
8c226322a0 Merge branch 'main' into update-requirements 2024-12-05 10:59:41 +08:00
Harry
3a7888937f Merge pull request #536 from Felix3322/main
better requirements.txt
2024-12-05 10:47:26 +08:00
yyhhyyyyyy
6760a0ad00 📝 docs: Update documentation 2024-12-05 10:34:09 +08:00
yyhhyyyyyy
6288b70ae2 ⬆️ deps: Upgrade dependencies to latest versions and address minor issues 2024-12-05 10:16:38 +08:00
Jiaying Liu
4adc010388 Update requirements.txt 2024-11-27 15:04:46 -05:00
Harry
162b5e17c3 Merge pull request #508 from flingjie/main
allow api key empty when using ollama
2024-11-20 15:45:40 +08:00
Harry
0d43ba2124 Merge pull request #505 from LucasHenriqueDiniz/main
feat: add PT-BR translation
2024-11-20 15:45:18 +08:00
Harry
080d8d82b4 Merge pull request #504 from Dreyfi/fix-403-error-pexels-request
Fix the response 403 from pexels - search_videos_pexels - failed to download videos, maybe the network is not available. if you are in China, please use a VPN.
2024-11-20 15:44:46 +08:00
Harry
fc50e16bc5 Merge pull request #486 from FLY-Open-AI/main
[Readme]Docker部署,启动命令优化。
2024-11-20 15:44:08 +08:00
Jie.F
345b6d59a1 allow api key empty when using ollama
the ollama API key is not required
2024-10-08 09:44:39 +08:00
Dreyfi
4ec19fd56a Add headers with user_agent to save_video request 2024-09-30 15:48:54 +10:00
Lucas Diniz
136630ec60 feat: add PT-BR translation 2024-09-29 19:30:12 -03:00
Dreyfi
9d3d99a595 Fix the response 403 from pexels
search_videos_pexels - failed to download videos, maybe the network is not available. if you are in China, please use a VPN.
2024-09-28 16:25:53 +10:00
wangyanfei
747c745ec0 [Readme]Docker部署,启动命令优化。最新版的docker安装时会自动以插件的形式安装docker compose,启动命令调整为docker compose up 2024-08-31 07:22:05 +08:00
Harry
a53ca843e8 Merge pull request #467 from harry0703/dev
update readme
2024-07-26 18:23:52 +08:00
harry
8b18d84d8a update readme 2024-07-26 18:23:04 +08:00
Harry
edc4df6eb5 Merge pull request #466 from harry0703/dev
fixed: subtitle generation failure
2024-07-26 17:56:32 +08:00
harry
5ed98d317c fixed: subtitle generation failure 2024-07-26 17:55:26 +08:00
Harry
c22ef5f1d2 Merge pull request #462 from harry0703/dev
update readme
2024-07-25 15:00:07 +08:00
harry
bcc9621976 update readme 2024-07-25 14:59:45 +08:00
Harry
6512e3f140 Merge pull request #461 from harry0703/dev
Optimize memory usage in moviepy
2024-07-25 13:58:46 +08:00
harry
931e1a0caa Optimize memory usage in moviepy
Upgrade version number to 1.2.0
2024-07-25 13:57:39 +08:00
yyhhyy
84ae8e5248 Merge pull request #460 from yyhhyyyyyy/code-formatting
Code Formatting
2024-07-25 13:39:05 +08:00
yyhhyyyyyy
5c2db3aa92 resolve issue with video concatenation
order always being random
2024-07-25 13:36:21 +08:00
yyhhyyyyyy
905841965a Format project code 2024-07-24 14:59:06 +08:00
Harry
bbd4e94941 Merge pull request #459 from yyhhyyyyyy/customize-subtitle-position
feat: support custom subtitle positioning
2024-07-24 14:35:50 +08:00
yyhhyyyyyy
b89250874b Change default value to 70.0 2024-07-24 14:31:56 +08:00
yyhhyyyyyy
e8b20c697d feat: support custom subtitle positioning 2024-07-24 14:25:20 +08:00
Harry
e64041c93d Merge pull request #458 from yyhhyyyyyy/refactor-task-add-subtitle-api
Refactor task.py and add subtitle API
2024-07-24 11:47:27 +08:00
yyhhyyyyyy
17b4a61e64 1.Refactor task.py to encapsulate separable functions.
2.Add a new subtitle API.
2024-07-23 17:00:23 +08:00
Harry
6d520a4266 Merge pull request #453 from yyhhyyyyyy/fit-oneapi
fit(oneapi):Fix the issue where model_name is always empty when using OneAPI as the LLM source.
2024-07-22 10:38:10 +08:00
yyhhyyyyyy
7ff8467f9d Fix the issue where model_name is always empty
when using OneAPI as the LLM source.
2024-07-20 09:36:19 +08:00
Harry
4cf9cefb5c Merge pull request #450 from yyhhyyyyyy/fit-subtitle-correct
fit(subtitle):Fix subtitle correction logic
2024-07-20 08:25:25 +08:00
yyhhyyyyyy
33534db8bb 1. .gitignore ignores the models folder
2. Fix subtitle correction logic
2024-07-19 15:00:17 +08:00
Harry
ec16f1c41b Merge pull request #449 from harry0703/dev
update readme
2024-07-19 14:21:56 +08:00
harry
9653d7d18a update readme 2024-07-19 14:21:35 +08:00
Harry
36a367d713 Merge pull request #448 from yyhhyyyyyy/add-rate
feat(azure_tts_v1): Allows to control the speed of speech generation.
2024-07-19 14:17:15 +08:00
yyhhyyyyyy
77b304537a Speech Rate 2024-07-19 11:15:36 +08:00
yyhhyyyyyy
63fb848a17 1. Add azure_tts_v1 to control the speed of speech 2024-07-19 11:06:34 +08:00
Harry
6853163905 Merge pull request #447 from harry0703/dev
update readme
2024-07-15 14:09:55 +08:00
harry
052c29b579 update readme 2024-07-15 14:09:33 +08:00
Harry
df62529f2a Merge pull request #443 from harry0703/dev
update readme
2024-07-09 13:41:04 +08:00
harry
934eff13ae update readme 2024-07-09 13:40:43 +08:00
Harry
0472338184 Merge pull request #437 from harry0703/dev
support baidu ERNIE llm
2024-07-03 21:13:51 +08:00
harry
66c81a04bf support baidu ERNIE llm 2024-07-03 21:12:21 +08:00
Harry
8dd66cf624 Merge pull request #435 from harry0703/dev
update readme
2024-07-02 10:00:53 +08:00
harry
dca23d99e4 update readme 2024-07-02 09:57:53 +08:00
Harry
42560cc7f5 Merge pull request #421 from harry0703/dev
update readme
2024-06-21 11:01:41 +08:00
harry
11478063e7 update readme 2024-06-21 11:01:15 +08:00
Harry
bf0dbcc045 Merge pull request #414 from harry0703/dev
update readme
2024-06-15 17:37:36 +08:00
harry
43df593ac3 update readme 2024-06-15 17:36:37 +08:00
Harry
7cf21c6541 Merge pull request #408 from harry0703/dev
update readme
2024-06-11 11:50:48 +08:00
harry
f76f905833 update readme 2024-06-11 11:48:04 +08:00
Harry
0f27c26042 Merge pull request #399 from harry0703/dev
update readme
2024-06-04 10:36:18 +08:00
harry
e1d7318cee update readme 2024-06-04 10:34:32 +08:00
Harry
6408c31b7f Merge pull request #391 from harry0703/dev
update readme
2024-05-28 18:41:24 +08:00
harry
b0d694db08 update readme 2024-05-28 14:51:03 +08:00
Harry
730c2a461a Merge pull request #381 from harry0703/dev
update readme
2024-05-23 18:21:05 +08:00
harry
bdb49a4c82 update readme 2024-05-23 18:20:45 +08:00
Harry
a4692060a0 Merge pull request #372 from harry0703/dev
enhanced exception handling for generating terms
2024-05-17 17:12:13 +08:00
harry
fc6844dd19 enhanced exception handling for generating terms 2024-05-17 17:11:35 +08:00
Harry
d740a6babd Merge pull request #370 from harry0703/dev
update readme
2024-05-17 08:44:01 +08:00
harry
9c58991830 update readme 2024-05-17 08:43:35 +08:00
Harry
09ad53a60e Merge pull request #369 from harry0703/dev
update version to 1.1.9
2024-05-16 16:36:54 +08:00
harry
bb4333db95 update version to 1.1.9 2024-05-16 16:36:26 +08:00
Harry
dc460c25d2 Merge pull request #368 from harry0703/dev
enhanced exception handling for llm and optimized video concatenation
2024-05-16 16:35:56 +08:00
harry
4ca8d8d8ae enhanced exception handling for llm and optimized video concatenation 2024-05-16 16:34:31 +08:00
harry
37e56239d9 remove log 2024-05-16 16:23:58 +08:00
Harry
ba8613baeb Merge pull request #366 from ATtendev/ft/shuffle
update shuffle video
2024-05-16 15:10:09 +08:00
Harry
fd8ed3bd69 Merge pull request #367 from harry0703/dev
update readme
2024-05-16 15:07:59 +08:00
harry
1b8b65f642 update readme 2024-05-16 15:07:31 +08:00
AT
2128a5fae5 chore: true shuffle video
- random duration splits video
- shuffle splits video
2024-05-16 10:11:10 +07:00
Harry
2976652509 Merge pull request #365 from harry0703/dev
update version to 1.1.8
2024-05-15 22:42:15 +08:00
harry
eb2b07b615 update version to 1.1.8 2024-05-15 22:41:52 +08:00
Harry
289c06799a Merge pull request #364 from harry0703/dev
support pixabay
2024-05-15 22:40:22 +08:00
harry
afb4eff3e5 support pixabay 2024-05-15 22:39:01 +08:00
harry
bdf7af0a12 fix the bug with the incorrect bgm_type parameter 2024-05-15 11:33:16 +08:00
Harry
ee931d1933 Merge pull request #362 from harry0703/dev
update readme
2024-05-14 13:48:43 +08:00
harry
cbd3495426 update readme 2024-05-14 13:48:18 +08:00
Harry
a864be83da Merge pull request #357 from harry0703/dev
support deepseek llm
2024-05-13 21:49:21 +08:00
harry
fee226a149 support deepseek llm 2024-05-13 21:48:53 +08:00
Harry
b3f549b4db Merge pull request #356 from harry0703/dev
update readme
2024-05-13 18:37:29 +08:00
harry
4c21a23a8b update readme 2024-05-13 18:36:58 +08:00
Harry
8d84b5b530 Merge pull request #355 from harry0703/dev
support voice preview, update version to 1.1.6
2024-05-13 18:34:13 +08:00
harry
6de3d6eedc 1. support voice preview
2, update version to 1.1.6
2024-05-13 18:29:59 +08:00
Harry
ee680d24cc Merge pull request #348 from harry0703/dev
update example configuration
2024-05-10 14:49:53 +08:00
harry
fb3aadeccc update example configuration 2024-05-10 14:49:18 +08:00
Harry
5254577e5c Merge pull request #346 from harry0703/dev
update wechat qrcode
2024-05-10 08:46:23 +08:00
harry
c15a3bdd13 update wechat qrcode 2024-05-10 08:46:01 +08:00
Harry
d8d8a5b602 Merge pull request #340 from harry0703/dev
update readme
2024-05-07 09:59:48 +08:00
harry
e258617d4f update readme 2024-05-07 09:58:07 +08:00
Harry
ce64602c08 Merge pull request #338 from harry0703/dev
update readme
2024-05-05 18:09:02 +08:00
harry
f729124a48 update readme 2024-05-05 18:08:32 +08:00
Harry
367018b3f7 Merge pull request #337 from harry0703/dev
update readme
2024-05-05 18:07:52 +08:00
harry
b055755689 update readme 2024-05-05 18:07:30 +08:00
Harry
c8894a851b Merge pull request #332 from harry0703/dev
update readme
2024-04-28 22:52:40 +08:00
harry
28c5bc372f update readme 2024-04-28 22:50:49 +08:00
Harry
7ed2603442 Merge pull request #331 from harry0703/dev
update readme
2024-04-28 17:20:32 +08:00
harry
1f8b41d2b3 update readme 2024-04-28 17:19:59 +08:00
Harry
ee09cf64d5 Merge pull request #330 from harry0703/dev
update readme
2024-04-28 17:17:16 +08:00
harry
4e886a1a73 update readme 2024-04-28 17:15:48 +08:00
Harry
661d8cb5ab Merge pull request #325 from harry0703/dev
support local videos
2024-04-27 08:52:10 +08:00
harry
4de02f4429 update version and readme 2024-04-27 08:35:40 +08:00
harry
5d06530a39 support local videos 2024-04-27 08:33:44 +08:00
Harry
4596804bcf Merge pull request #321 from harry0703/dev
update tips and add hide_config
2024-04-26 14:49:20 +08:00
harry
c161ab3124 add hide_config 2024-04-26 14:48:33 +08:00
harry
376955d4a0 update tips 2024-04-26 14:43:11 +08:00
Harry
b08b79f9cf Merge pull request #320 from harry0703/dev
1. Added exception handling when loading the Whisper model. 2. Check if the file exists before verifying the subtitles.
2024-04-26 11:15:17 +08:00
harry
7582022fa9 1. Added exception handling when loading the Whisper model.
2. Check if the file exists before verifying the subtitles.
2024-04-26 11:14:35 +08:00
Harry
8dbce05344 Merge pull request #319 from harry0703/dev
update readme
2024-04-26 11:09:09 +08:00
harry
4d91a83858 update readme 2024-04-26 11:08:44 +08:00
Harry
8e93dd7ca0 Merge pull request #318 from KPCOFGS/main
更新了英文的README文件
2024-04-26 10:28:30 +08:00
Shi Sheng
d2277715df Update README-en.md 2024-04-25 15:06:29 -04:00
Shi Sheng
8861526e0a Update README-en.md 2024-04-25 15:03:51 -04:00
Shi Sheng
f38fc60394 Update README-en.md 2024-04-25 15:01:56 -04:00
Shi Sheng
e77bff3ffb Update README-en.md 2024-04-25 15:00:53 -04:00
Shi Sheng
59a518ce9d Update README-en.md 2024-04-25 14:59:39 -04:00
Harry
d922ff2576 Merge pull request #315 from harry0703/dev
update readme
2024-04-25 10:50:53 +08:00
harry
84b3ef13c0 update readme 2024-04-25 10:49:28 +08:00
Harry
a53da162ac Merge pull request #311 from harry0703/dev
RUN chmod 777 /MoneyPrinterTurbo
2024-04-24 13:48:08 +08:00
harry
e77389ffb5 RUN chmod 777 /MoneyPrinterTurbo 2024-04-24 13:47:37 +08:00
Harry
2be09365d9 Merge pull request #310 from harry0703/dev
update readme
2024-04-24 13:46:25 +08:00
harry
f85c11118d update readme 2024-04-24 13:45:57 +08:00
Kevin Zhang
419abd760e Merge pull request #290 from KevinZhang19870314/main
doc: landing for MoneyPrinterTurbo
2024-04-23 10:58:07 +08:00
Harry
5c0a905c09 Merge pull request #302 from harry0703/dev
update docker base image
2024-04-22 18:33:34 +08:00
harry
3c5ef29775 update docker base image 2024-04-22 18:33:00 +08:00
Harry
bc45d4bcf3 Merge pull request #301 from harry0703/dev
optimize UI and code
2024-04-22 17:59:40 +08:00
harry
e38c79bfad rollback docker base image 2024-04-22 17:59:04 +08:00
harry
c7c7b4847e optimize code 2024-04-22 16:25:13 +08:00
harry
6a5c4e9e73 optimize UI 2024-04-22 16:24:32 +08:00
harry
2eb6d4b5cc update example configuration and library 2024-04-22 16:00:33 +08:00
Harry
13f3abffd0 Merge pull request #294 from harry0703/dev
fixed: WS_OPEN_ERROR_UNDERLYING_IO_OPEN_FAILED
2024-04-22 08:41:10 +08:00
harry
6164920eaa fixed: WS_OPEN_ERROR_UNDERLYING_IO_OPEN_FAILED, thanks to https://github.com/oicid 2024-04-22 08:40:32 +08:00
kevin.zhang
a8d528f41c Merge branch 'main' of https://github.com/KevinZhang19870314/MoneyPrinterTurbo 2024-04-19 14:23:59 +08:00
kevin.zhang
259b3e94fc doc: sites 2024-04-19 14:22:45 +08:00
Kevin Zhang
ffcb52fd46 Merge pull request #286 from KevinZhang19870314/dev 2024-04-18 18:18:32 +08:00
kevin.zhang
ee4337e847 chore: test sites changes 2024-04-18 18:08:47 +08:00
kevin.zhang
16bcec96ee chore: fix 2024-04-18 18:07:53 +08:00
kevin.zhang
cc231e0b62 chore: fix 2024-04-18 18:07:10 +08:00
kevin.zhang
cb2c53334e chore: fix 2024-04-18 17:58:58 +08:00
kevin.zhang
643b0fb30c chore: fix 2024-04-18 17:43:29 +08:00
kevin.zhang
6b97e7dbd4 chore: fix 2024-04-18 17:38:13 +08:00
kevin.zhang
4804aa1e04 chore: ci/cd 2024-04-18 17:35:37 +08:00
kevin.zhang
c04e3988fe Merge branch 'main' of https://github.com/KevinZhang19870314/MoneyPrinterTurbo 2024-04-18 16:15:29 +08:00
kevin.zhang
c9eb16e0a9 chore: fix 2024-04-18 15:44:57 +08:00
kevin.zhang
2d599db892 feat: add sites for MoneyPrinterTurbo 2024-04-18 15:23:02 +08:00
Harry
ec50cd0184 Merge pull request #285 from vuisme/main
Update requirements.txt
2024-04-18 15:10:22 +08:00
cpanel10x
a1a1a51881 Update requirements.txt
Fix error
streamlit 1.32.0 depends on packaging<24 
git-changelog 2.5.2 depends on packaging>=24.0
2024-04-18 14:01:12 +07:00
Harry
0066bab3ec Merge pull request #283 from KevinZhang19870314/dev
chore: add changelog auto gen
2024-04-18 10:41:40 +08:00
kevin.zhang
2f461d961c Merge branch 'dev' of https://github.com/KevinZhang19870314/MoneyPrinterTurbo into dev 2024-04-18 10:29:22 +08:00
kevin.zhang
77c250ce18 chore: add gitattributes 2024-04-18 10:29:05 +08:00
kevin.zhang
3f941c8dd9 chore: add changelog auto gen 2024-04-18 09:48:58 +08:00
Harry
ab5ae7072b Merge pull request #280 from harry0703/dev
v1.1.3
2024-04-17 17:26:47 +08:00
harry
93da539519 update version to 1.1.3 2024-04-17 17:25:28 +08:00
harry
5280159f41 added microsoft yahei fonts 2024-04-17 17:25:10 +08:00
harry
729c407c30 fixed: ValidationError: 1 validation error for VideoParams video_subject Field required 2024-04-17 17:24:00 +08:00
harry
73ec0cf7ad Merge remote-tracking branch 'origin/dev' into dev 2024-04-17 17:17:55 +08:00
harry
a5273b31b3 Merge branch 'main' into dev 2024-04-17 17:13:41 +08:00
Harry
f83c374ef4 Merge pull request #277 from vuisme/main
Add Vietnamese
2024-04-17 17:12:18 +08:00
Harry
da010c476f Merge pull request #278 from KevinZhang19870314/dev
chore: optimize Dockerfile
2024-04-17 17:10:50 +08:00
kevin.zhang
78f5ce7cdd Merge branch 'dev' of https://github.com/KevinZhang19870314/MoneyPrinterTurbo into dev 2024-04-17 16:02:06 +08:00
kevin.zhang
27553114b0 chore: optimize Dockerfile 2024-04-17 16:01:45 +08:00
vuisme
4ae022c059 clean 2024-04-17 11:07:20 +07:00
vuisme
70ed2b5c82 clean 2024-04-17 11:07:07 +07:00
vuisme
89f001742a rm 2024-04-17 11:04:45 +07:00
cpanel10x
6c8b4f665d Merge branch 'harry0703:main' into main 2024-04-17 10:59:22 +07:00
vuisme
1c35e50563 Add vietnamese and sample font Vietnamese. String pre-translated by chatGPT 2024-04-17 10:57:16 +07:00
Harry
add34a92f7 Merge pull request #267 from KevinZhang19870314/dev
feat: add support for maximum concurrency of /api/v1/videos
2024-04-17 11:09:03 +08:00
Harry
a44016a7cf Merge pull request #275 from harry0703/dev
add timeout
2024-04-17 10:56:18 +08:00
harry
0521d46826 add timeout 2024-04-17 10:55:57 +08:00
cpanel10x
f48fb24dcf Merge branch 'harry0703:main' into main 2024-04-17 09:47:16 +07:00
Harry
996cd55462 Merge pull request #272 from harry0703/dev
update readme
2024-04-16 22:15:23 +08:00
harry
97640d2199 update readme 2024-04-16 22:14:51 +08:00
Kevin Zhang
f6857d63f9 Merge branch 'harry0703:dev' into dev 2024-04-16 17:51:46 +08:00
kevin.zhang
abe12abd7b feat: add support for maximum concurrency of /api/v1/videos 2024-04-16 17:47:56 +08:00
cpanel10x
7e8c901fd4 Merge branch 'harry0703:main' into main 2024-04-16 15:28:30 +07:00
Harry
1cee2bbb8d Merge pull request #266 from harry0703/dev
update readme
2024-04-16 14:25:57 +08:00
harry
4d176c6107 update readme 2024-04-16 14:25:35 +08:00
Harry
e499f8a1c4 Merge pull request #265 from harry0703/dev
update readme
2024-04-16 09:48:28 +08:00
harry
d82b5f76e0 update readme 2024-04-16 09:47:01 +08:00
Harry
414bcb0621 Merge pull request #264 from harry0703/dev
support azure new speech voice and fix the bug where clip were not closed
2024-04-16 09:00:10 +08:00
harry
d4eb7bc333 optimize code 2024-04-15 17:47:10 +08:00
harry
1e96357f00 fix the bug where the last subtitle line was missing 2024-04-15 17:46:56 +08:00
harry
2e58d7ccf2 fix the bug where clip were not closed 2024-04-15 17:46:24 +08:00
harry
176660b442 support azure new speech voice 2024-04-15 17:45:05 +08:00
Harry
b9b9bea2a6 Merge pull request #261 from KevinZhang19870314/main
chore: add video download api endpoint
2024-04-15 17:23:29 +08:00
Kevin Zhang
17df9a1f27 Merge branch 'harry0703:main' into main 2024-04-15 14:49:36 +08:00
kevin.zhang
00052b4c50 chore: add video download api endpoint 2024-04-15 14:47:57 +08:00
Harry
b8369349ea Merge pull request #260 from harry0703/dev
optimize subtitle segmentation and code
2024-04-15 11:30:33 +08:00
Harry
3de3e19276 Merge pull request #259 from KevinZhang19870314/main
refactor: video stream api revise
2024-04-15 11:30:10 +08:00
harry
bd33419460 optimize subtitle segmentation
optimize code
2024-04-15 11:29:04 +08:00
kevin.zhang
d13a3cf6e9 refactor: Streaming MP4 files in the browser using video html element instead of waiting for the entire file to download before playing 2024-04-15 09:51:40 +08:00
Harry
3e4d5f52fd Merge pull request #258 from harry0703/dev
update readme
2024-04-14 21:18:11 +08:00
harry
9a1ee9abfb update readme 2024-04-14 21:16:51 +08:00
Harry
2c41e6be62 Merge pull request #254 from harry0703/dev
optimize segmentation
2024-04-13 21:51:33 +08:00
harry
a17d52c1ae optimize segmentation 2024-04-13 21:50:45 +08:00
Harry
b1506b9161 Merge pull request #253 from harry0703/dev
optimize segmentation
2024-04-13 21:04:22 +08:00
harry
53923e0d25 optimize segmentation 2024-04-13 21:03:55 +08:00
Harry
1a302a1791 Merge pull request #252 from harry0703/dev
fix some bugs
2024-04-13 20:26:52 +08:00
harry
ce0f557702 1. prioritize using the bgm_file.
2. optimized the logic for looping the BGM.
2024-04-13 20:24:09 +08:00
harry
a8d208bdc3 added validation for video file using moviepy to ensure video file is valid before processing. 2024-04-13 20:19:08 +08:00
Harry
0cb71d6218 Merge pull request #251 from harry0703/dev
write_videofile set fps=30
2024-04-12 22:02:25 +08:00
harry
52b92d175d write_videofile set fps=30 2024-04-12 22:02:01 +08:00
Harry
76e1407d9b Merge pull request #250 from harry0703/dev
update readme
2024-04-12 18:54:51 +08:00
harry
26437a666c update readme 2024-04-12 18:54:25 +08:00
Harry
8907958fec Merge pull request #249 from KevinZhang19870314/main
add password support for redis state and stream api support for video
2024-04-12 17:54:35 +08:00
kevin.zhang
0550e433d1 Merge branch 'main' of https://github.com/KevinZhang19870314/MoneyPrinterTurbo 2024-04-12 17:47:14 +08:00
kevin.zhang
1fb3399b02 chore: add stream support for video 2024-04-12 17:43:21 +08:00
Harry
9ab13a74a2 Merge pull request #248 from elf-mouse/main
fix: response parsing bug for gemini
2024-04-12 17:03:01 +08:00
elf-mouse
1dbfcfadab Merge branch 'main' of github.com:elf-mouse/MoneyPrinterTurbo 2024-04-12 16:50:32 +08:00
elf-mouse
ee7306d216 fix: response parsing bug for gemini 2024-04-12 15:49:23 +08:00
Harry
a8b54415a5 Merge pull request #242 from harry0703/dev
update readme
2024-04-12 10:22:40 +08:00
harry
7a8e25dc36 update readme 2024-04-12 10:22:01 +08:00
Harry
c8adc453ae Merge pull request #241 from harry0703/dev
add qwen error logs
2024-04-12 10:08:08 +08:00
harry
24a9ca514e add qwen error logs 2024-04-12 10:05:14 +08:00
harry
a7466b2393 add qwen error logs 2024-04-12 10:04:52 +08:00
Harry
1f2b36a4f0 Merge pull request #240 from harry0703/dev
fix webui.bat and docker-compose.yml
2024-04-11 23:40:21 +08:00
harry
91218ecf95 fix webui.bat and docker-compose.yml 2024-04-11 23:39:49 +08:00
Harry
a0a5a4059f Merge pull request #238 from highkay/main
增加Cloudflare workers ai作为llm后端
2024-04-11 23:11:17 +08:00
highkay
90f0f560b2 Merge branch 'main' of https://github.com/harry0703/MoneyPrinterTurbo into main 2024-04-11 22:55:18 +08:00
highkay
05da4a3766 - 增加Cloudflare workers ai作为llm后端
- 增加一些gitignore
2024-04-11 22:55:08 +08:00
Harry
066e33def9 Merge pull request #237 from harry0703/dev
update readme
2024-04-11 22:03:17 +08:00
harry
bb66b7e10c update readme 2024-04-11 22:02:00 +08:00
cpanel10x
1ac643f4d0 Merge branch 'harry0703:main' into main 2024-04-10 16:01:43 +07:00
cpanel10x
fea396585f Merge branch 'harry0703:main' into main 2024-04-10 14:19:13 +07:00
cpanel10x
3fe6ff42c8 Merge branch 'harry0703:main' into main 2024-04-09 16:04:36 +07:00
cpanel10x
a71e54fd7c Merge branch 'harry0703:main' into main 2024-03-28 10:22:36 +07:00
cpanel10x
fee660fb8c Added Dev Container Folder 2024-03-27 15:27:34 +07:00
114 changed files with 10223 additions and 1281 deletions

View File

@@ -21,3 +21,4 @@ __pycache__/
.svn/ .svn/
storage/ storage/
config.toml

81
.github/ISSUE_TEMPLATE/bug_report.yml vendored Normal file
View File

@@ -0,0 +1,81 @@
name: 🐛 Bug
description: 出现错误或未按预期工作
title: "请在此处填写标题"
labels:
- bug
body:
- type: markdown
attributes:
value: |
**在提交此问题之前,请确保您已阅读以下文档:[Getting Started (英文)](https://github.com/harry0703/MoneyPrinterTurbo/blob/main/README-en.md#system-requirements-) 或 [快速开始 (中文)](https://github.com/harry0703/MoneyPrinterTurbo/blob/main/README.md#%E5%BF%AB%E9%80%9F%E5%BC%80%E5%A7%8B-)。**
**请填写以下信息:**
- type: checkboxes
attributes:
label: 是否已存在类似问题?
description: |
请务必检查此问题是否已有用户反馈。
在提交新问题前,使用 GitHub 的问题搜索框(包括已关闭的问题)或通过 Google、StackOverflow 等工具搜索,确认该问题是否重复。
您可能已经可以找到解决问题的方法!
options:
- label: 我已搜索现有问题
required: true
- type: textarea
attributes:
label: 当前行为
description: 描述您当前遇到的情况。
placeholder: |
MoneyPrinterTurbo 未按预期工作。当我执行某个操作时,视频未成功生成/程序报错了...
validations:
required: true
- type: textarea
attributes:
label: 预期行为
description: 描述您期望发生的情况。
placeholder: |
当我执行某个操作时,程序应当...
validations:
required: true
- type: textarea
attributes:
label: 重现步骤
description: 描述重现问题的步骤。描述的越详细,越有助于定位和修复问题。
validations:
required: true
- type: textarea
attributes:
label: 堆栈追踪/日志
description: |
如果您有任何堆栈追踪或日志,请将它们粘贴在此处。(注意不要包含敏感信息)
validations:
required: true
- type: input
attributes:
label: Python 版本
description: 您遇到此问题时使用的 Python 版本。
placeholder: v3.13.0, v3.10.0 等
validations:
required: true
- type: input
attributes:
label: 操作系统
description: 您使用 MoneyPrinterTurbo 遇到问题时的操作系统信息。
placeholder: macOS 14.1, Windows 11 等
validations:
required: true
- type: input
attributes:
label: MoneyPrinterTurbo 版本
description: 您在哪个版本的 MoneyPrinterTurbo 中遇到了此问题?
placeholder: v1.2.2 等
validations:
required: true
- type: textarea
attributes:
label: 其他信息
description: 您还有什么其他信息想补充吗?例如问题的截图或视频记录。
validations:
required: false

1
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View File

@@ -0,0 +1 @@
blank_issues_enabled: false

View File

@@ -0,0 +1,38 @@
name: ✨ 增加功能
description: 为此项目提出一个新想法
title: "请在此处填写标题"
labels:
- enhancement
body:
- type: checkboxes
attributes:
label: 是否已存在类似的功能请求?
description: 请确保此功能请求是否重复。
options:
- label: 我已搜索现有的功能请求
required: true
- type: textarea
attributes:
label: 痛点
description: 请解释您的功能请求。
placeholder: 我希望可以实现这一点
validations:
required: true
- type: textarea
attributes:
label: 建议的解决方案
description: 请描述您能想到的解决方案。
placeholder: 您可以添加这个功能 / 更改这个流程 / 使用某种方法
validations:
required: true
- type: textarea
attributes:
label: 有用的资源
description: 请提供一些有助于实现您建议的资源。
- type: textarea
attributes:
label: 其他信息
description: 您还有什么其他想补充的信息吗?例如问题的截图或视频记录。
validations:
required: false

33
.github/workflows/gh-pages.yml vendored Normal file
View File

@@ -0,0 +1,33 @@
name: gp-pages
on:
push:
branches:
- main
paths:
- 'sites/**'
jobs:
build-deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v2
- name: Set-up Node
uses: actions/setup-node@v1
with:
node-version: "18.15.0"
- name: Install pnpm
run: npm install -g pnpm
- name: Install dependencies
run: pnpm i
working-directory: ./sites
- name: Build gh-pages
run: pnpm docs:build
working-directory: ./sites
- name: Deploy to gh-pages
uses: crazy-max/ghaction-github-pages@v1
with:
target_branch: gh-pages
build_dir: sites/docs/.vuepress/dist
env:
GITHUB_TOKEN: ${{ secrets.MY_TOKEN }}

18
.gitignore vendored
View File

@@ -9,4 +9,20 @@
/app/utils/__pycache__/ /app/utils/__pycache__/
/*/__pycache__/* /*/__pycache__/*
.vscode .vscode
/**/.streamlit /**/.streamlit
__pycache__
logs/
node_modules
# VuePress 默认临时文件目录
/sites/docs/.vuepress/.temp
# VuePress 默认缓存目录
/sites/docs/.vuepress/.cache
# VuePress 默认构建生成的静态文件目录
/sites/docs/.vuepress/dist
# 模型目录
/models/
./models/*
venv/
.venv

1
.pdm-python Normal file
View File

@@ -0,0 +1 @@
./MoneyPrinterTurbo/.venv/bin/python

22
CHANGELOG.md Normal file
View File

@@ -0,0 +1,22 @@
<!-- insertion marker -->
## [1.1.2](https://github.com/KevinZhang19870314/MoneyPrinterTurbo/releases/tag/1.1.2) - 2024-04-18
<small>[Compare with first commit](https://github.com/KevinZhang19870314/MoneyPrinterTurbo/compare/d4f7b53b841e65da658e3d77822f9923286ddab6...1.1.2)</small>
### Features
- add support for maximum concurrency of /api/v1/videos ([abe12ab](https://github.com/KevinZhang19870314/MoneyPrinterTurbo/commit/abe12abd7b78997651468ad5dd656985066f8bd9) by kevin.zhang).
- add task deletion endpoint ([d57434e](https://github.com/KevinZhang19870314/MoneyPrinterTurbo/commit/d57434e0d31c8195dbcd3c86ff2763af96736cdf) by kevin.zhang).
- add redis support for task state management ([3d45348](https://github.com/KevinZhang19870314/MoneyPrinterTurbo/commit/3d453486627234937c7bfe6f176890360074696b) by kevin.zhang).
- enable cors to allow play video through mounted videos url ([3b1871d](https://github.com/KevinZhang19870314/MoneyPrinterTurbo/commit/3b1871d591873594bb4aa8dc17a1253b3a7563a3) by kevin.zhang).
- add /api/v1/get_bgm_list and /api/v1/upload_bgm_file ([6d8911f](https://github.com/KevinZhang19870314/MoneyPrinterTurbo/commit/6d8911f5bf496e7c5dd718309a302df88d11817b) by cathy).
- return combined videos in /api/v1/tasks response ([28199c9](https://github.com/KevinZhang19870314/MoneyPrinterTurbo/commit/28199c93b78f67e9a6bf50f290f1591078f63da8) by cathy).
- add Dockerfile ([f3b3c7f](https://github.com/KevinZhang19870314/MoneyPrinterTurbo/commit/f3b3c7fb47b01ed4ecba44eaebf29f5d6d2cb7b5) by kevin.zhang).
### Bug Fixes
- response parsing bug for gemini ([ee7306d](https://github.com/KevinZhang19870314/MoneyPrinterTurbo/commit/ee7306d216ea41e40855bbca396cacb094d572db) by elf-mouse).
### Code Refactoring
- Streaming MP4 files in the browser using video html element instead of waiting for the entire file to download before playing ([d13a3cf](https://github.com/KevinZhang19870314/MoneyPrinterTurbo/commit/d13a3cf6e911d1573c62b1f6459c3c0b7a1bc18d) by kevin.zhang).

View File

@@ -1,9 +1,12 @@
# Use an official Python runtime as a parent image # Use an official Python runtime as a parent image
FROM python:3.10-slim FROM python:3.11-slim-bullseye
# Set the working directory in the container # Set the working directory in the container
WORKDIR /MoneyPrinterTurbo WORKDIR /MoneyPrinterTurbo
# 设置/MoneyPrinterTurbo目录权限为777
RUN chmod 777 /MoneyPrinterTurbo
ENV PYTHONPATH="/MoneyPrinterTurbo" ENV PYTHONPATH="/MoneyPrinterTurbo"
# Install system dependencies # Install system dependencies
@@ -16,12 +19,15 @@ RUN apt-get update && apt-get install -y \
# Fix security policy for ImageMagick # Fix security policy for ImageMagick
RUN sed -i '/<policy domain="path" rights="none" pattern="@\*"/d' /etc/ImageMagick-6/policy.xml RUN sed -i '/<policy domain="path" rights="none" pattern="@\*"/d' /etc/ImageMagick-6/policy.xml
# Copy the current directory contents into the container at /MoneyPrinterTurbo # Copy only the requirements.txt first to leverage Docker cache
COPY . . COPY requirements.txt ./
# Install Python dependencies # Install Python dependencies
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt
# Now copy the rest of the codebase into the image
COPY . .
# Expose the port the app runs on # Expose the port the app runs on
EXPOSE 8501 EXPOSE 8501
@@ -35,4 +41,4 @@ CMD ["streamlit", "run", "./webui/Main.py","--browser.serverAddress=127.0.0.1","
## For Linux or MacOS: ## For Linux or MacOS:
# docker run -v $(pwd)/config.toml:/MoneyPrinterTurbo/config.toml -v $(pwd)/storage:/MoneyPrinterTurbo/storage -p 8501:8501 moneyprinterturbo # docker run -v $(pwd)/config.toml:/MoneyPrinterTurbo/config.toml -v $(pwd)/storage:/MoneyPrinterTurbo/storage -p 8501:8501 moneyprinterturbo
## For Windows: ## For Windows:
# docker run -v %cd%/config.toml:/MoneyPrinterTurbo/config.toml -v %cd%/storage:/MoneyPrinterTurbo/storage -p 8501:8501 moneyprinterturbo # docker run -v ${PWD}/config.toml:/MoneyPrinterTurbo/config.toml -v ${PWD}/storage:/MoneyPrinterTurbo/storage -p 8501:8501 moneyprinterturbo

View File

@@ -10,9 +10,9 @@
<h3>English | <a href="README.md">简体中文</a></h3> <h3>English | <a href="README.md">简体中文</a></h3>
<div align="center">
> Thanks to [RootFTW](https://github.com/Root-FTW) for the translation <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>
</div>
Simply provide a <b>topic</b> or <b>keyword</b> for a video, and it will automatically generate the video copy, video Simply provide a <b>topic</b> or <b>keyword</b> for a video, and it will automatically generate the video copy, video
materials, video subtitles, and video background music before synthesizing a high-definition short video. materials, video subtitles, and video background music before synthesizing a high-definition short video.
@@ -35,9 +35,18 @@ like to express our special thanks to
**RecCloud (AI-Powered Multimedia Service Platform)** for providing a free `AI Video Generator` service based on this **RecCloud (AI-Powered Multimedia Service Platform)** for providing a free `AI Video Generator` service based on this
project. It allows for online use without deployment, which is very convenient. project. It allows for online use without deployment, which is very convenient.
https://reccloud.com - Chinese version: https://reccloud.cn
- English version: https://reccloud.com
![](docs/reccloud.com.jpg) ![](docs/reccloud.cn.jpg)
## Thanks for Sponsorship 🙏
Thanks to Picwish https://picwish.cn for supporting and sponsoring this project, enabling continuous updates and maintenance.
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.
![picwish.jpg](docs/picwish.jpg)
## Features 🎯 ## Features 🎯
@@ -51,29 +60,26 @@ https://reccloud.com
satisfactory one satisfactory one
- [x] Supports setting the **duration of video clips**, facilitating adjustments to material switching frequency - [x] Supports setting the **duration of video clips**, facilitating adjustments to material switching frequency
- [x] Supports video copy in both **Chinese** and **English** - [x] Supports video copy in both **Chinese** and **English**
- [x] Supports **multiple voice** synthesis - [x] Supports **multiple voice** synthesis, with **real-time preview** of effects
- [x] Supports **subtitle generation**, with adjustable `font`, `position`, `color`, `size`, and also - [x] Supports **subtitle generation**, with adjustable `font`, `position`, `color`, `size`, and also
supports `subtitle outlining` supports `subtitle outlining`
- [x] Supports **background music**, either random or specified music files, with adjustable `background music volume` - [x] Supports **background music**, either random or specified music files, with adjustable `background music volume`
- [x] Video material sources are **high-definition** and **royalty-free** - [x] Video material sources are **high-definition** and **royalty-free**, and you can also use your own **local materials**
- [x] Supports integration with various models such as **OpenAI**, **moonshot**, **Azure**, **gpt4free**, **one-api**, - [x] Supports integration with various models such as **OpenAI**, **Moonshot**, **Azure**, **gpt4free**, **one-api**,
**qianwen**, **Google Gemini**, **Ollama** and more **Qwen**, **Google Gemini**, **Ollama**, **DeepSeek**, **ERNIE** and more
- For users in China, it is recommended to use **DeepSeek** or **Moonshot** as the large model provider (directly accessible in China, no VPN needed. Free credits upon registration, generally sufficient for use)
❓[How to Use the Free OpenAI GPT-3.5 Model?](https://github.com/harry0703/MoneyPrinterTurbo/blob/main/README-en.md#common-questions-)
❓[How to Use the Free OpenAI GPT-3.5 Model?](https://github.com/harry0703/MoneyPrinterTurbo/blob/main/README-en.md#common-questions-)
### Future Plans 📅 ### Future Plans 📅
- [ ] Introduce support for GPT-SoVITS dubbing - [ ] GPT-SoVITS dubbing support
- [ ] Enhance voice synthesis with large models for a more natural and emotionally resonant voice output - [ ] Optimize voice synthesis using large models for more natural and emotionally rich voice output
- [ ] Incorporate video transition effects to ensure a smoother viewing experience - [ ] Add video transition effects for a smoother viewing experience
- [ ] Improve the relevance of video content - [ ] Add more video material sources, improve the matching between video materials and script
- [ ] Add options for video length: short, medium, long - [ ] Add video length options: short, medium, long
- [ ] Package the application into a one-click launch bundle for Windows and macOS for ease of use - [ ] Support more voice synthesis providers, such as OpenAI TTS
- [ ] Enable the use of custom materials - [ ] Automate upload to YouTube platform
- [ ] Offer voiceover and background music options with real-time preview
- [ ] Support a wider range of voice synthesis providers, such as OpenAI TTS, Azure TTS
- [ ] Automate the upload process to the YouTube platform
## Video Demos 📺 ## Video Demos 📺
@@ -111,10 +117,32 @@ https://reccloud.com
</tbody> </tbody>
</table> </table>
## System Requirements 📦
- Recommended minimum 4 CPU cores or more, 8G of memory or more, GPU is not required
- Windows 10 or MacOS 11.0, and their later versions
## Quick Start 🚀
Download the one-click startup package, extract and use directly (the path should not contain **Chinese characters**, **special characters**, or **spaces**)
### Windows
- Baidu Netdisk (1.2.1 latest version): https://pan.baidu.com/s/1pSNjxTYiVENulTLm6zieMQ?pwd=g36q Extraction code: g36q
After downloading, it is recommended to **double-click** `update.bat` first to update to the **latest code**, then double-click `start.bat` to launch
After launching, the browser will open automatically (if it opens blank, it is recommended to use **Chrome** or **Edge**)
### Other Systems
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.
## Installation & Deployment 📥 ## Installation & Deployment 📥
### Prerequisites
- Try to avoid using **Chinese paths** to prevent unpredictable issues - Try to avoid using **Chinese paths** to prevent unpredictable issues
- Ensure your **network** is stable, meaning you can access foreign websites normally - Ensure your **network** is stable, VPN needs to be in `global traffic` mode
#### ① Clone the Project #### ① Clone the Project
@@ -128,11 +156,6 @@ git clone https://github.com/harry0703/MoneyPrinterTurbo.git
- Follow the instructions in the `config.toml` file to configure `pexels_api_keys` and `llm_provider`, and according to - Follow the instructions in the `config.toml` file to configure `pexels_api_keys` and `llm_provider`, and according to
the llm_provider's service provider, set up the corresponding API Key the llm_provider's service provider, set up the corresponding API Key
#### ③ Configure Large Language Models (LLM)
- To use `GPT-4.0` or `GPT-3.5`, you need an `API Key` from `OpenAI`. If you don't have one, you can set `llm_provider`
to `g4f` (a free-to-use GPT library https://github.com/xtekky/gpt4free)
### Docker Deployment 🐳 ### Docker Deployment 🐳
#### ① Launch the Docker Container #### ① Launch the Docker Container
@@ -148,6 +171,8 @@ cd MoneyPrinterTurbo
docker-compose up docker-compose up
``` ```
> NoteThe 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 `
#### ② Access the Web Interface #### ② Access the Web Interface
Open your browser and visit http://0.0.0.0:8501 Open your browser and visit http://0.0.0.0:8501
@@ -158,27 +183,28 @@ Open your browser and visit http://0.0.0.0:8080/docs Or http://0.0.0.0:8080/redo
### Manual Deployment 📦 ### Manual Deployment 📦
#### ① Create a Python Virtual Environment > Video tutorials
>
> - Complete usage demonstration: https://v.douyin.com/iFhnwsKY/
> - How to deploy on Windows: https://v.douyin.com/iFyjoW3M
It is recommended to create a Python virtual environment #### ① Install Dependencies
using [conda](https://conda.io/projects/conda/en/latest/user-guide/install/index.html)
It is recommended to use [pdm](https://pdm-project.org/en/latest/#installation)
```shell ```shell
git clone https://github.com/harry0703/MoneyPrinterTurbo.git git clone https://github.com/harry0703/MoneyPrinterTurbo.git
cd MoneyPrinterTurbo cd MoneyPrinterTurbo
conda create -n MoneyPrinterTurbo python=3.10 pdm sync
conda activate MoneyPrinterTurbo
pip install -r requirements.txt
``` ```
#### ② Install ImageMagick #### ② Install ImageMagick
###### Windows: ###### Windows:
- Download https://imagemagick.org/archive/binaries/ImageMagick-7.1.1-29-Q16-x64-static.exe - 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
- Install the downloaded ImageMagick, **do not change the installation path** - Install the downloaded ImageMagick, **do not change the installation path**
- Modify the `config.toml` configuration file, set `imagemagick_path` to your actual installation path (if you didn't - Modify the `config.toml` configuration file, set `imagemagick_path` to your actual installation path
change the path during installation, just uncomment it)
###### MacOS: ###### MacOS:
@@ -205,14 +231,12 @@ Note that you need to execute the following commands in the `root directory` of
###### Windows ###### Windows
```bat ```bat
conda activate MoneyPrinterTurbo
webui.bat webui.bat
``` ```
###### MacOS or Linux ###### MacOS or Linux
```shell ```shell
conda activate MoneyPrinterTurbo
sh webui.sh sh webui.sh
``` ```
@@ -231,13 +255,15 @@ online for a quick experience.
A list of all supported voices can be viewed here: [Voice List](./docs/voice-list.txt) A list of all supported voices can be viewed here: [Voice List](./docs/voice-list.txt)
2024-04-16 v1.1.2 Added 9 new Azure voice synthesis voices that require API KEY configuration. These voices sound more realistic.
## Subtitle Generation 📜 ## Subtitle Generation 📜
Currently, there are 2 ways to generate subtitles: Currently, there are 2 ways to generate subtitles:
- edge: Faster generation speed, better performance, no specific requirements for computer configuration, but the - **edge**: Faster generation speed, better performance, no specific requirements for computer configuration, but the
quality may be unstable quality may be unstable
- whisper: Slower generation speed, poorer performance, specific requirements for computer configuration, but more - **whisper**: Slower generation speed, poorer performance, specific requirements for computer configuration, but more
reliable quality reliable quality
You can switch between them by modifying the `subtitle_provider` in the `config.toml` configuration file You can switch between them by modifying the `subtitle_provider` in the `config.toml` configuration file
@@ -245,7 +271,31 @@ You can switch between them by modifying the `subtitle_provider` in the `config.
It is recommended to use `edge` mode, and switch to `whisper` mode if the quality of the subtitles generated is not It is recommended to use `edge` mode, and switch to `whisper` mode if the quality of the subtitles generated is not
satisfactory. satisfactory.
> If left blank, it means no subtitles will be generated. > Note:
>
> 1. In whisper mode, you need to download a model file from HuggingFace, about 3GB in size, please ensure good internet connectivity
> 2. If left blank, it means no subtitles will be generated.
> Since HuggingFace is not accessible in China, you can use the following methods to download the `whisper-large-v3` model file
Download links:
- Baidu Netdisk: https://pan.baidu.com/s/11h3Q6tsDtjQKTjUu3sc5cA?pwd=xjs9
- Quark Netdisk: https://pan.quark.cn/s/3ee3d991d64b
After downloading the model, extract it and place the entire directory in `.\MoneyPrinterTurbo\models`,
The final file path should look like this: `.\MoneyPrinterTurbo\models\whisper-large-v3`
```
MoneyPrinterTurbo
├─models
│ └─whisper-large-v3
│ config.json
│ model.bin
│ preprocessor_config.json
│ tokenizer.json
│ vocabulary.json
```
## Background Music 🎵 ## Background Music 🎵
@@ -261,12 +311,16 @@ own fonts.
## Common Questions 🤔 ## Common Questions 🤔
### ❓How to Use the Free OpenAI GPT-3.5 Model? ### ❓How to Use the Free OpenAI GPT-3.5 Model?
[OpenAI has announced that ChatGPT with 3.5 is now free](https://openai.com/blog/start-using-chatgpt-instantly), and developers have wrapped it into an API for direct usage.
[OpenAI has announced that ChatGPT with 3.5 is now free](https://openai.com/blog/start-using-chatgpt-instantly), and
developers have wrapped it into an API for direct usage.
**Ensure you have Docker installed and running**. Execute the following command to start the Docker service: **Ensure you have Docker installed and running**. Execute the following command to start the Docker service:
```shell ```shell
docker run -p 3040:3040 missuo/freegpt35 docker run -p 3040:3040 missuo/freegpt35
``` ```
Once successfully started, modify the `config.toml` configuration as follows: Once successfully started, modify the `config.toml` configuration as follows:
- Set `llm_provider` to `openai` - Set `llm_provider` to `openai`
@@ -274,6 +328,16 @@ Once successfully started, modify the `config.toml` configuration as follows:
- Change `openai_base_url` to `http://localhost:3040/v1/` - Change `openai_base_url` to `http://localhost:3040/v1/`
- Set `openai_model_name` to `gpt-3.5-turbo` - Set `openai_model_name` to `gpt-3.5-turbo`
> Note: This method may be unstable
### ❓AttributeError: 'str' object has no attribute 'choices'
This issue is caused by the large language model not returning a correct response.
It's likely a network issue. Use a **VPN**, or set `openai_base_url` to your proxy, which should solve the problem.
At the same time, it is recommended to use **Moonshot** or **DeepSeek** as the large model provider, as these service providers have faster access and are more stable in China.
### ❓RuntimeError: No ffmpeg exe could be found ### ❓RuntimeError: No ffmpeg exe could be found
Normally, ffmpeg will be automatically downloaded and detected. Normally, ffmpeg will be automatically downloaded and detected.
@@ -298,14 +362,14 @@ ffmpeg_path = "C:\\Users\\harry\\Downloads\\ffmpeg.exe"
[issue 56](https://github.com/harry0703/MoneyPrinterTurbo/issues/56) [issue 56](https://github.com/harry0703/MoneyPrinterTurbo/issues/56)
``` ```
failed to generate audio, maybe the network is not available. failed to generate audio, maybe the network is not available.
if you are in China, please use a VPN. if you are in China, please use a VPN.
``` ```
[issue 44](https://github.com/harry0703/MoneyPrinterTurbo/issues/44) [issue 44](https://github.com/harry0703/MoneyPrinterTurbo/issues/44)
``` ```
failed to download videos, maybe the network is not available. failed to download videos, maybe the network is not available.
if you are in China, please use a VPN. if you are in China, please use a VPN.
``` ```
@@ -325,6 +389,43 @@ For Linux systems, you can manually install it, refer to https://cn.linux-consol
Thanks to [@wangwenqiao666](https://github.com/wangwenqiao666) for their research and exploration Thanks to [@wangwenqiao666](https://github.com/wangwenqiao666) for their research and exploration
### ❓ImageMagick's security policy prevents operations related to temporary file @/tmp/tmpur5hyyto.txt
You can find these policies in ImageMagick's configuration file policy.xml.
This file is usually located in /etc/ImageMagick-`X`/ or a similar location in the ImageMagick installation directory.
Modify the entry containing `pattern="@"`, change `rights="none"` to `rights="read|write"` to allow read and write operations on files.
### ❓OSError: [Errno 24] Too many open files
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.
Check the current limit:
```shell
ulimit -n
```
If it's too low, you can increase it, for example:
```shell
ulimit -n 10240
```
### ❓Whisper model download failed, with the following error
LocalEntryNotfoundEror: Cannot find an appropriate cached snapshotfolderfor the specified revision on the local disk and
outgoing trafic has been disabled.
To enablerepo look-ups and downloads online, pass 'local files only=False' as input.
or
An error occured while synchronizing the model Systran/faster-whisper-large-v3 from the Hugging Face Hub:
An error happened while trying to locate the files on the Hub and we cannot find the appropriate snapshot folder for the
specified revision on the local disk. Please check your internet connection and try again.
Trying to load the model directly from the local cache, if it exists.
Solution: [Click to see how to manually download the model from netdisk](#subtitle-generation-)
## Feedback & Suggestions 📢 ## Feedback & Suggestions 📢
- You can submit an [issue](https://github.com/harry0703/MoneyPrinterTurbo/issues) or - You can submit an [issue](https://github.com/harry0703/MoneyPrinterTurbo/issues) or
@@ -341,4 +442,4 @@ Click to view the [`LICENSE`](LICENSE) file
## Star History ## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=harry0703/MoneyPrinterTurbo&type=Date)](https://star-history.com/#harry0703/MoneyPrinterTurbo&Date) [![Star History Chart](https://api.star-history.com/svg?repos=harry0703/MoneyPrinterTurbo&type=Date)](https://star-history.com/#harry0703/MoneyPrinterTurbo&Date)

236
README.md
View File

@@ -9,6 +9,9 @@
</p> </p>
<br> <br>
<h3>简体中文 | <a href="README-en.md">English</a></h3> <h3>简体中文 | <a href="README-en.md">English</a></h3>
<div align="center">
<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>
</div>
<br> <br>
只需提供一个视频 <b>主题</b> 或 <b>关键词</b> ,就可以全自动生成视频文案、视频素材、视频字幕、视频背景音乐,然后合成一个高清的短视频。 只需提供一个视频 <b>主题</b> 或 <b>关键词</b> ,就可以全自动生成视频文案、视频素材、视频字幕、视频背景音乐,然后合成一个高清的短视频。
<br> <br>
@@ -26,7 +29,6 @@
## 特别感谢 🙏 ## 特别感谢 🙏
由于该项目的 **部署****使用**,对于一些小白用户来说,还是 **有一定的门槛**,在此特别感谢 由于该项目的 **部署****使用**,对于一些小白用户来说,还是 **有一定的门槛**,在此特别感谢
**录咖AI智能 多媒体服务平台)** 网站基于该项目,提供的免费`AI视频生成器`服务,可以不用部署,直接在线使用,非常方便。 **录咖AI智能 多媒体服务平台)** 网站基于该项目,提供的免费`AI视频生成器`服务,可以不用部署,直接在线使用,非常方便。
- 中文版https://reccloud.cn - 中文版https://reccloud.cn
@@ -34,6 +36,14 @@
![](docs/reccloud.cn.jpg) ![](docs/reccloud.cn.jpg)
## 感谢赞助 🙏
感谢佐糖 https://picwish.cn 对该项目的支持和赞助,使得该项目能够持续的更新和维护。
佐糖专注于**图像处理领域**,提供丰富的**图像处理工具**,将复杂操作极致简化,真正实现让图像处理更简单。
![picwish.jpg](docs/picwish.jpg)
## 功能特性 🎯 ## 功能特性 🎯
- [x] 完整的 **MVC架构**,代码 **结构清晰**,易于维护,支持 `API``Web界面` - [x] 完整的 **MVC架构**,代码 **结构清晰**,易于维护,支持 `API``Web界面`
@@ -42,15 +52,15 @@
- [x] 竖屏 9:16`1080x1920` - [x] 竖屏 9:16`1080x1920`
- [x] 横屏 16:9`1920x1080` - [x] 横屏 16:9`1920x1080`
- [x] 支持 **批量视频生成**,可以一次生成多个视频,然后选择一个最满意的 - [x] 支持 **批量视频生成**,可以一次生成多个视频,然后选择一个最满意的
- [x] 支持 **视频片段时长**设置,方便调节素材切换频率 - [x] 支持 **视频片段时长** 设置,方便调节素材切换频率
- [x] 支持 **中文****英文** 视频文案 - [x] 支持 **中文****英文** 视频文案
- [x] 支持 **多种语音** 合成 - [x] 支持 **多种语音** 合成,可 **实时试听** 效果
- [x] 支持 **字幕生成**,可以调整 `字体``位置``颜色``大小`,同时支持`字幕描边`设置 - [x] 支持 **字幕生成**,可以调整 `字体``位置``颜色``大小`,同时支持`字幕描边`设置
- [x] 支持 **背景音乐**,随机或者指定音乐文件,可设置`背景音乐音量` - [x] 支持 **背景音乐**,随机或者指定音乐文件,可设置`背景音乐音量`
- [x] 视频素材来源 **高清**,而且 **无版权** - [x] 视频素材来源 **高清**,而且 **无版权**,也可以使用自己的 **本地素材**
- [x] 支持 **OpenAI**、**moonshot**、**Azure**、**gpt4free**、**one-api**、**通义千问**、**Google Gemini**、**Ollama** 等多种模型接入 - [x] 支持 **OpenAI**、**Moonshot**、**Azure**、**gpt4free**、**one-api**、**通义千问**、**Google Gemini**、**Ollama**
**DeepSeek****文心一言** 等多种模型接入
❓[如何使用免费的 **OpenAI GPT-3.5** 模型?](https://github.com/harry0703/MoneyPrinterTurbo?tab=readme-ov-file#%E5%B8%B8%E8%A7%81%E9%97%AE%E9%A2%98-) - 中国用户建议使用 **DeepSeek****Moonshot** 作为大模型提供商国内可直接访问不需要VPN。注册就送额度基本够用
### 后期计划 📅 ### 后期计划 📅
@@ -59,11 +69,7 @@
- [ ] 增加视频转场效果,使其看起来更加的流畅 - [ ] 增加视频转场效果,使其看起来更加的流畅
- [ ] 增加更多视频素材来源,优化视频素材和文案的匹配度 - [ ] 增加更多视频素材来源,优化视频素材和文案的匹配度
- [ ] 增加视频长度选项:短、中、长 - [ ] 增加视频长度选项:短、中、长
- [ ] 打包成一键启动包WindowsmacOS方便使用 - [ ] 支持更多的语音合成服务商,比如 OpenAI TTS
- [ ] 增加免费网络代理让访问OpenAI和素材下载不再受限
- [ ] 可以使用自己的素材
- [ ] 朗读声音和背景音乐,提供实时试听
- [ ] 支持更多的语音合成服务商,比如 OpenAI TTS, Azure TTS
- [ ] 自动上传到YouTube平台 - [ ] 自动上传到YouTube平台
## 视频演示 📺 ## 视频演示 📺
@@ -74,12 +80,14 @@
<thead> <thead>
<tr> <tr>
<th align="center"><g-emoji class="g-emoji" alias="arrow_forward">▶️</g-emoji> 《如何增加生活的乐趣》</th> <th align="center"><g-emoji class="g-emoji" alias="arrow_forward">▶️</g-emoji> 《如何增加生活的乐趣》</th>
<th align="center"><g-emoji class="g-emoji" alias="arrow_forward">▶️</g-emoji> 《金钱的作用》<br>更真实的合成声音</th>
<th align="center"><g-emoji class="g-emoji" alias="arrow_forward">▶️</g-emoji> 《生命的意义是什么》</th> <th align="center"><g-emoji class="g-emoji" alias="arrow_forward">▶️</g-emoji> 《生命的意义是什么》</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr> <tr>
<td align="center"><video src="https://github.com/harry0703/MoneyPrinterTurbo/assets/4928832/a84d33d5-27a2-4aba-8fd0-9fb2bd91c6a6"></video></td> <td align="center"><video src="https://github.com/harry0703/MoneyPrinterTurbo/assets/4928832/a84d33d5-27a2-4aba-8fd0-9fb2bd91c6a6"></video></td>
<td align="center"><video src="https://github.com/harry0703/MoneyPrinterTurbo/assets/4928832/af2f3b0b-002e-49fe-b161-18ba91c055e8"></video></td>
<td align="center"><video src="https://github.com/harry0703/MoneyPrinterTurbo/assets/4928832/112c9564-d52b-4472-99ad-970b75f66476"></video></td> <td align="center"><video src="https://github.com/harry0703/MoneyPrinterTurbo/assets/4928832/112c9564-d52b-4472-99ad-970b75f66476"></video></td>
</tr> </tr>
</tbody> </tbody>
@@ -102,8 +110,26 @@
</tbody> </tbody>
</table> </table>
## 配置要求 📦
- 建议最低 CPU 4核或以上内存 8G 或以上,显卡非必须
- Windows 10 或 MacOS 11.0 以上系统
## 快速开始 🚀
下载一键启动包,解压直接使用(路径不要有 **中文**、**特殊字符**、**空格**
### Windows
- 百度网盘1.2.1 老版本): https://pan.baidu.com/s/1pSNjxTYiVENulTLm6zieMQ?pwd=g36q 提取码: g36q
下载后,建议先**双击执行** `update.bat` 更新到**最新代码**,然后双击 `start.bat` 启动
启动后,会自动打开浏览器(如果打开是空白,建议换成 **Chrome** 或者 **Edge** 打开)
## 安装部署 📥 ## 安装部署 📥
### 前提条件
- 尽量不要使用 **中文路径**,避免出现一些无法预料的问题 - 尽量不要使用 **中文路径**,避免出现一些无法预料的问题
- 请确保你的 **网络** 是正常的VPN需要打开`全局流量`模式 - 请确保你的 **网络** 是正常的VPN需要打开`全局流量`模式
@@ -113,20 +139,12 @@
git clone https://github.com/harry0703/MoneyPrinterTurbo.git git clone https://github.com/harry0703/MoneyPrinterTurbo.git
``` ```
#### ② 修改配置文件 #### ② 修改配置文件(可选,建议启动后也可以在 WebUI 里面配置)
-`config.example.toml` 文件复制一份,命名为 `config.toml` -`config.example.toml` 文件复制一份,命名为 `config.toml`
- 按照 `config.toml` 文件中的说明,配置好 `pexels_api_keys``llm_provider`,并根据 llm_provider 对应的服务商,配置相关的 - 按照 `config.toml` 文件中的说明,配置好 `pexels_api_keys``llm_provider`,并根据 llm_provider 对应的服务商,配置相关的
API Key API Key
#### ③ 配置大模型(LLM)
- 如果要使用 `GPT-4.0``GPT-3.5`,需要有 `OpenAI``API Key`,如果没有,可以将 `llm_provider` 设置为 `g4f` (
一个免费使用GPT的开源库 https://github.com/xtekky/gpt4free ,但是该免费的服务,稳定性较差,有时候可以用,有时候用不了)
- 或者可以使用到 [月之暗面](https://platform.moonshot.cn/console/api-keys) 申请。注册就送
15元体验金可以对话1500次左右。然后设置 `llm_provider="moonshot"``moonshot_api_key`
- 也可以使用 通义千问,具体请看配置文件里面的注释说明
### Docker部署 🐳 ### Docker部署 🐳
#### ① 启动Docker #### ① 启动Docker
@@ -134,6 +152,7 @@ git clone https://github.com/harry0703/MoneyPrinterTurbo.git
如果未安装 Docker请先安装 https://www.docker.com/products/docker-desktop/ 如果未安装 Docker请先安装 https://www.docker.com/products/docker-desktop/
如果是Windows系统请参考微软的文档 如果是Windows系统请参考微软的文档
1. https://learn.microsoft.com/zh-cn/windows/wsl/install 1. https://learn.microsoft.com/zh-cn/windows/wsl/install
2. https://learn.microsoft.com/zh-cn/windows/wsl/tutorials/wsl-containers 2. https://learn.microsoft.com/zh-cn/windows/wsl/tutorials/wsl-containers
@@ -142,6 +161,8 @@ cd MoneyPrinterTurbo
docker-compose up docker-compose up
``` ```
> 注意最新版的docker安装时会自动以插件的形式安装docker compose启动命令调整为docker compose up
#### ② 访问Web界面 #### ② 访问Web界面
打开浏览器,访问 http://0.0.0.0:8501 打开浏览器,访问 http://0.0.0.0:8501
@@ -157,43 +178,36 @@ docker-compose up
- 完整的使用演示https://v.douyin.com/iFhnwsKY/ - 完整的使用演示https://v.douyin.com/iFhnwsKY/
- 如何在Windows上部署https://v.douyin.com/iFyjoW3M - 如何在Windows上部署https://v.douyin.com/iFyjoW3M
#### ① 创建虚拟环境 #### ① 依赖安装
建议使用 [conda](https://conda.io/projects/conda/en/latest/user-guide/install/index.html) 创建 python 虚拟环境 建议使用 [pdm](https://pdm-project.org/en/latest/#installation)
```shell ```shell
git clone https://github.com/harry0703/MoneyPrinterTurbo.git git clone https://github.com/harry0703/MoneyPrinterTurbo.git
cd MoneyPrinterTurbo cd MoneyPrinterTurbo
conda create -n MoneyPrinterTurbo python=3.10 pdm sync
conda activate MoneyPrinterTurbo
pip install -r requirements.txt
``` ```
#### ② 安装好 ImageMagick #### ② 安装好 ImageMagick
###### Windows: - Windows:
- 下载 https://imagemagick.org/script/download.php 选择Windows版本切记一定要选择 **静态库** 版本,比如
ImageMagick-7.1.1-32-Q16-x64-**static**.exe
- 安装下载好的 ImageMagick**注意不要修改安装路径**
- 修改 `配置文件 config.toml` 中的 `imagemagick_path` 为你的 **实际安装路径**
- 下载 https://imagemagick.org/archive/binaries/ImageMagick-7.1.1-30-Q16-x64-static.exe - MacOS:
- 安装下载好的 ImageMagick注意不要修改安装路径 ```shell
- 修改 `配置文件 config.toml` 中的 `imagemagick_path` 为你的实际安装路径(如果安装的时候没有修改路径,直接取消注释即可) brew install imagemagick
````
###### MacOS: - Ubuntu
```shell
```shell sudo apt-get install imagemagick
brew install imagemagick ```
```` - CentOS
```shell
###### Ubuntu sudo yum install ImageMagick
```
```shell
sudo apt-get install imagemagick
```
###### CentOS
```shell
sudo yum install ImageMagick
```
#### ③ 启动Web界面 🌐 #### ③ 启动Web界面 🌐
@@ -202,17 +216,16 @@ sudo yum install ImageMagick
###### Windows ###### Windows
```bat ```bat
conda activate MoneyPrinterTurbo
webui.bat webui.bat
``` ```
###### MacOS or Linux ###### MacOS or Linux
```shell ```shell
conda activate MoneyPrinterTurbo
sh webui.sh sh webui.sh
``` ```
启动后,会自动打开浏览器
启动后,会自动打开浏览器(如果打开是空白,建议换成 **Chrome** 或者 **Edge** 打开)
#### ④ 启动API服务 🚀 #### ④ 启动API服务 🚀
@@ -226,21 +239,45 @@ python main.py
所有支持的声音列表,可以查看:[声音列表](./docs/voice-list.txt) 所有支持的声音列表,可以查看:[声音列表](./docs/voice-list.txt)
2024-04-16 v1.1.2 新增了9种Azure的语音合成声音需要配置API KEY该声音合成的更加真实。
## 字幕生成 📜 ## 字幕生成 📜
当前支持2种字幕生成方式 当前支持2种字幕生成方式
- edge: 生成速度更快,性能更好,对电脑配置没有要求,但是质量可能不稳定 - **edge**: 生成`速度快`,性能更好,对电脑配置没有要求,但是质量可能不稳定
- whisper: 生成速度较慢,性能较差,对电脑配置有一定要求,但是质量更可靠。 - **whisper**: 生成`速度慢`,性能较差,对电脑配置有一定要求,但是`质量更可靠`
可以修改 `config.toml` 配置文件中的 `subtitle_provider` 进行切换 可以修改 `config.toml` 配置文件中的 `subtitle_provider` 进行切换
建议使用 `edge` 模式,如果生成的字幕质量不好,再切换到 `whisper` 模式 建议使用 `edge` 模式,如果生成的字幕质量不好,再切换到 `whisper` 模式
> 注意: > 注意:
1. whisper 模式下需要到 HuggingFace 下载一个模型文件,大约 3GB 左右,请确保网络通畅 1. whisper 模式下需要到 HuggingFace 下载一个模型文件,大约 3GB 左右,请确保网络通畅
2. 如果留空,表示不生成字幕。 2. 如果留空,表示不生成字幕。
> 由于国内无法访问 HuggingFace可以使用以下方法下载 `whisper-large-v3` 的模型文件
下载地址:
- 百度网盘: https://pan.baidu.com/s/11h3Q6tsDtjQKTjUu3sc5cA?pwd=xjs9
- 夸克网盘https://pan.quark.cn/s/3ee3d991d64b
模型下载后解压,整个目录放到 `.\MoneyPrinterTurbo\models` 里面,
最终的文件路径应该是这样: `.\MoneyPrinterTurbo\models\whisper-large-v3`
```
MoneyPrinterTurbo
├─models
│ └─whisper-large-v3
│ config.json
│ model.bin
│ preprocessor_config.json
│ tokenizer.json
│ vocabulary.json
```
## 背景音乐 🎵 ## 背景音乐 🎵
用于视频的背景音乐,位于项目的 `resource/songs` 目录下。 用于视频的背景音乐,位于项目的 `resource/songs` 目录下。
@@ -252,26 +289,6 @@ python main.py
## 常见问题 🤔 ## 常见问题 🤔
### ❓如何使用免费的OpenAI GPT-3.5模型?
[OpenAI宣布ChatGPT里面3.5已经免费了](https://openai.com/blog/start-using-chatgpt-instantly)有开发者将其封装成了API可以直接调用
**确保你安装和启动了docker服务**执行以下命令启动docker服务
```shell
docker run -p 3040:3040 missuo/freegpt35
```
启动成功后,修改 `config.toml` 中的配置
- `llm_provider` 设置为 `openai`
- `openai_api_key` 随便填写一个即可,比如 '123456'
- `openai_base_url` 改为 `http://localhost:3040/v1/`
- `openai_model_name` 改为 `gpt-3.5-turbo`
### ❓AttributeError: 'str' object has no attribute 'choices'`
这个问题是由于 OpenAI 或者其他 LLM没有返回正确的回复导致的。
大概率是网络原因, 使用 **VPN**,或者设置 `openai_base_url` 为你的代理 ,应该就可以解决了。
### ❓RuntimeError: No ffmpeg exe could be found ### ❓RuntimeError: No ffmpeg exe could be found
通常情况下ffmpeg 会被自动下载,并且会被自动检测到。 通常情况下ffmpeg 会被自动下载,并且会被自动检测到。
@@ -290,52 +307,14 @@ Install ffmpeg on your system, or set the IMAGEIO_FFMPEG_EXE environment variabl
ffmpeg_path = "C:\\Users\\harry\\Downloads\\ffmpeg.exe" ffmpeg_path = "C:\\Users\\harry\\Downloads\\ffmpeg.exe"
``` ```
### ❓生成音频时报错或下载视频报错
[issue 56](https://github.com/harry0703/MoneyPrinterTurbo/issues/56)
```
failed to generate audio, maybe the network is not available.
if you are in China, please use a VPN.
```
[issue 44](https://github.com/harry0703/MoneyPrinterTurbo/issues/44)
```
failed to download videos, maybe the network is not available.
if you are in China, please use a VPN.
```
这个大概率是网络原因无法访问境外的服务请使用VPN解决。
### ❓ImageMagick is not installed on your computer
[issue 33](https://github.com/harry0703/MoneyPrinterTurbo/issues/33)
1. 按照 `示例配置` 里面提供的 `下载地址`
,安装 https://imagemagick.org/archive/binaries/ImageMagick-7.1.1-29-Q16-x64-static.exe, 用静态库
2. 不要安装在中文路径里面,避免出现一些无法预料的问题
[issue 54](https://github.com/harry0703/MoneyPrinterTurbo/issues/54#issuecomment-2017842022)
如果是linux系统可以手动安装参考 https://cn.linux-console.net/?p=16978
感谢 [@wangwenqiao666](https://github.com/wangwenqiao666)的研究探索
### ❓ImageMagick的安全策略阻止了与临时文件@/tmp/tmpur5hyyto.txt相关的操作 ### ❓ImageMagick的安全策略阻止了与临时文件@/tmp/tmpur5hyyto.txt相关的操作
[issue 92](https://github.com/harry0703/MoneyPrinterTurbo/issues/92)
可以在ImageMagick的配置文件policy.xml中找到这些策略。 可以在ImageMagick的配置文件policy.xml中找到这些策略。
这个文件通常位于 /etc/ImageMagick-`X`/ 或 ImageMagick 安装目录的类似位置。 这个文件通常位于 /etc/ImageMagick-`X`/ 或 ImageMagick 安装目录的类似位置。
修改包含`pattern="@"`的条目,将`rights="none"`更改为`rights="read|write"`以允许对文件的读写操作。 修改包含`pattern="@"`的条目,将`rights="none"`更改为`rights="read|write"`以允许对文件的读写操作。
感谢 [@chenhengzh](https://github.com/chenhengzh)的研究探索
### ❓OSError: [Errno 24] Too many open files ### ❓OSError: [Errno 24] Too many open files
[issue 100](https://github.com/harry0703/MoneyPrinterTurbo/issues/100)
这个问题是由于系统打开文件数限制导致的,可以通过修改系统的文件打开数限制来解决。 这个问题是由于系统打开文件数限制导致的,可以通过修改系统的文件打开数限制来解决。
查看当前限制 查看当前限制
@@ -350,39 +329,25 @@ ulimit -n
ulimit -n 10240 ulimit -n 10240
``` ```
### ❓AttributeError: module 'PIL.Image' has no attribute 'ANTIALIAS' ### ❓Whisper 模型下载失败,出现如下错误
[issue 101](https://github.com/harry0703/MoneyPrinterTurbo/issues/101), LocalEntryNotfoundEror: Cannot find an appropriate cached snapshotfolderfor the specified revision on the local disk and
[issue 83](https://github.com/harry0703/MoneyPrinterTurbo/issues/83), outgoing trafic has been disabled.
[issue 70](https://github.com/harry0703/MoneyPrinterTurbo/issues/70) To enablerepo look-ups and downloads online, pass 'local files only=False' as input.
先看下当前的 Pillow 版本是多少 或者
```shell An error occured while synchronizing the model Systran/faster-whisper-large-v3 from the Hugging Face Hub:
pip list |grep Pillow An error happened while trying to locate the files on the Hub and we cannot find the appropriate snapshot folder for the
``` specified revision on the local disk. Please check your internet connection and try again.
Trying to load the model directly from the local cache, if it exists.
如果是 10.x 的版本,可以尝试下降级看看,有用户反馈降级后正常 解决方法:[点击查看如何从网盘手动下载模型](#%E5%AD%97%E5%B9%95%E7%94%9F%E6%88%90-)
```shell
pip uninstall Pillow
pip install Pillow==9.5.0
# 或者降级到 8.4.0
pip install Pillow==8.4.0
```
## 反馈建议 📢 ## 反馈建议 📢
- 可以提交 [issue](https://github.com/harry0703/MoneyPrinterTurbo/issues) - 可以提交 [issue](https://github.com/harry0703/MoneyPrinterTurbo/issues)
或者 [pull request](https://github.com/harry0703/MoneyPrinterTurbo/pulls)。 或者 [pull request](https://github.com/harry0703/MoneyPrinterTurbo/pulls)。
- 也可以关注我的 **抖音****视频号**`网旭哈瑞.AI`
- 我会在上面发布一些 **使用教程****纯技术** 分享。
- 如果有更新和优化,我也会在上面 **及时通知**
- 有问题也可以在上面 **留言**,我会 **尽快回复**
| 抖音 | | 视频号 |
|:---------------------------------------:|:------------:|:-------------------------------------------:|
| <img src="docs/douyin.jpg" width="180"> | | <img src="docs/shipinghao.jpg" width="200"> |
## 参考项目 📚 ## 参考项目 📚
@@ -393,7 +358,6 @@ pip install Pillow==8.4.0
点击查看 [`LICENSE`](LICENSE) 文件 点击查看 [`LICENSE`](LICENSE) 文件
## Star History ## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=harry0703/MoneyPrinterTurbo&type=Date)](https://star-history.com/#harry0703/MoneyPrinterTurbo&Date) [![Star History Chart](https://api.star-history.com/svg?repos=harry0703/MoneyPrinterTurbo&type=Date)](https://star-history.com/#harry0703/MoneyPrinterTurbo&Date)

View File

@@ -1,12 +1,13 @@
"""Application implementation - ASGI.""" """Application implementation - ASGI."""
import os import os
from fastapi import FastAPI, Request from fastapi import FastAPI, Request
from fastapi.exceptions import RequestValidationError from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from loguru import logger
from fastapi.staticfiles import StaticFiles
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from fastapi.staticfiles import StaticFiles
from loguru import logger
from app.config import config from app.config import config
from app.models.exception import HttpException from app.models.exception import HttpException
@@ -24,7 +25,9 @@ def exception_handler(request: Request, e: HttpException):
def validation_exception_handler(request: Request, e: RequestValidationError): def validation_exception_handler(request: Request, e: RequestValidationError):
return JSONResponse( return JSONResponse(
status_code=400, status_code=400,
content=utils.get_response(status=400, data=e.errors(), message='field required'), content=utils.get_response(
status=400, data=e.errors(), message="field required"
),
) )
@@ -61,7 +64,9 @@ app.add_middleware(
) )
task_dir = utils.task_dir() task_dir = utils.task_dir()
app.mount("/tasks", StaticFiles(directory=task_dir, html=True, follow_symlink=True), name="") app.mount(
"/tasks", StaticFiles(directory=task_dir, html=True, follow_symlink=True), name=""
)
public_dir = utils.public_dir() public_dir = utils.public_dir()
app.mount("/", StaticFiles(directory=public_dir, html=True), name="") app.mount("/", StaticFiles(directory=public_dir, html=True), name="")

View File

@@ -10,7 +10,9 @@ from app.utils import utils
def __init_logger(): def __init_logger():
# _log_file = utils.storage_dir("logs/server.log") # _log_file = utils.storage_dir("logs/server.log")
_lvl = config.log_level _lvl = config.log_level
root_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.realpath(__file__)))) root_dir = os.path.dirname(
os.path.dirname(os.path.dirname(os.path.realpath(__file__)))
)
def format_record(record): def format_record(record):
# 获取日志记录中的文件全路径 # 获取日志记录中的文件全路径
@@ -21,10 +23,13 @@ def __init_logger():
record["file"].path = f"./{relative_path}" record["file"].path = f"./{relative_path}"
# 返回修改后的格式字符串 # 返回修改后的格式字符串
# 您可以根据需要调整这里的格式 # 您可以根据需要调整这里的格式
_format = '<green>{time:%Y-%m-%d %H:%M:%S}</> | ' + \ _format = (
'<level>{level}</> | ' + \ "<green>{time:%Y-%m-%d %H:%M:%S}</> | "
'"{file.path}:{line}":<blue> {function}</> ' + \ + "<level>{level}</> | "
'- <level>{message}</>' + "\n" + '"{file.path}:{line}":<blue> {function}</> '
+ "- <level>{message}</>"
+ "\n"
)
return _format return _format
logger.remove() logger.remove()

View File

@@ -1,7 +1,8 @@
import os import os
import socket
import toml
import shutil import shutil
import socket
import toml
from loguru import logger from loguru import logger
root_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.realpath(__file__)))) root_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.realpath(__file__))))
@@ -17,7 +18,7 @@ def load_config():
example_file = f"{root_dir}/config.example.toml" example_file = f"{root_dir}/config.example.toml"
if os.path.isfile(example_file): if os.path.isfile(example_file):
shutil.copyfile(example_file, config_file) shutil.copyfile(example_file, config_file)
logger.info(f"copy config.example.toml to config.toml") logger.info("copy config.example.toml to config.toml")
logger.info(f"load config from file: {config_file}") logger.info(f"load config from file: {config_file}")
@@ -25,7 +26,7 @@ def load_config():
_config_ = toml.load(config_file) _config_ = toml.load(config_file)
except Exception as e: except Exception as e:
logger.warning(f"load config failed: {str(e)}, try to load as utf-8-sig") logger.warning(f"load config failed: {str(e)}, try to load as utf-8-sig")
with open(config_file, mode="r", encoding='utf-8-sig') as fp: with open(config_file, mode="r", encoding="utf-8-sig") as fp:
_cfg_content = fp.read() _cfg_content = fp.read()
_config_ = toml.loads(_cfg_content) _config_ = toml.loads(_cfg_content)
return _config_ return _config_
@@ -34,16 +35,19 @@ def load_config():
def save_config(): def save_config():
with open(config_file, "w", encoding="utf-8") as f: with open(config_file, "w", encoding="utf-8") as f:
_cfg["app"] = app _cfg["app"] = app
_cfg["whisper"] = whisper _cfg["azure"] = azure
_cfg["pexels"] = pexels _cfg["ui"] = ui
f.write(toml.dumps(_cfg)) f.write(toml.dumps(_cfg))
_cfg = load_config() _cfg = load_config()
app = _cfg.get("app", {}) app = _cfg.get("app", {})
whisper = _cfg.get("whisper", {}) whisper = _cfg.get("whisper", {})
pexels = _cfg.get("pexels", {}) proxy = _cfg.get("proxy", {})
ui = _cfg.get("ui", {}) azure = _cfg.get("azure", {})
ui = _cfg.get("ui", {
"hide_log": False,
})
hostname = socket.gethostname() hostname = socket.gethostname()
@@ -51,9 +55,11 @@ log_level = _cfg.get("log_level", "DEBUG")
listen_host = _cfg.get("listen_host", "0.0.0.0") listen_host = _cfg.get("listen_host", "0.0.0.0")
listen_port = _cfg.get("listen_port", 8080) listen_port = _cfg.get("listen_port", 8080)
project_name = _cfg.get("project_name", "MoneyPrinterTurbo") project_name = _cfg.get("project_name", "MoneyPrinterTurbo")
project_description = _cfg.get("project_description", project_description = _cfg.get(
"<a href='https://github.com/harry0703/MoneyPrinterTurbo'>https://github.com/harry0703/MoneyPrinterTurbo</a>") "project_description",
project_version = _cfg.get("project_version", "1.1.0") "<a href='https://github.com/harry0703/MoneyPrinterTurbo'>https://github.com/harry0703/MoneyPrinterTurbo</a>",
)
project_version = _cfg.get("project_version", "1.2.5")
reload_debug = False reload_debug = False
imagemagick_path = app.get("imagemagick_path", "") imagemagick_path = app.get("imagemagick_path", "")
@@ -63,3 +69,5 @@ if imagemagick_path and os.path.isfile(imagemagick_path):
ffmpeg_path = app.get("ffmpeg_path", "") ffmpeg_path = app.get("ffmpeg_path", "")
if ffmpeg_path and os.path.isfile(ffmpeg_path): if ffmpeg_path and os.path.isfile(ffmpeg_path):
os.environ["IMAGEIO_FFMPEG_EXE"] = ffmpeg_path os.environ["IMAGEIO_FFMPEG_EXE"] = ffmpeg_path
logger.info(f"{project_name} v{project_version}")

View File

@@ -7,14 +7,14 @@ from app.models.exception import HttpException
def get_task_id(request: Request): def get_task_id(request: Request):
task_id = request.headers.get('x-task-id') task_id = request.headers.get("x-task-id")
if not task_id: if not task_id:
task_id = uuid4() task_id = uuid4()
return str(task_id) return str(task_id)
def get_api_key(request: Request): def get_api_key(request: Request):
api_key = request.headers.get('x-api-key') api_key = request.headers.get("x-api-key")
return api_key return api_key
@@ -23,5 +23,9 @@ def verify_token(request: Request):
if token != config.app.get("api_key", ""): if token != config.app.get("api_key", ""):
request_id = get_task_id(request) request_id = get_task_id(request)
request_url = request.url request_url = request.url
user_agent = request.headers.get('user-agent') user_agent = request.headers.get("user-agent")
raise HttpException(task_id=request_id, status_code=401, message=f"invalid token: {request_url}, {user_agent}") raise HttpException(
task_id=request_id,
status_code=401,
message=f"invalid token: {request_url}, {user_agent}",
)

View File

@@ -0,0 +1,64 @@
import threading
from typing import Any, Callable, Dict
class TaskManager:
def __init__(self, max_concurrent_tasks: int):
self.max_concurrent_tasks = max_concurrent_tasks
self.current_tasks = 0
self.lock = threading.Lock()
self.queue = self.create_queue()
def create_queue(self):
raise NotImplementedError()
def add_task(self, func: Callable, *args: Any, **kwargs: Any):
with self.lock:
if self.current_tasks < self.max_concurrent_tasks:
print(f"add task: {func.__name__}, current_tasks: {self.current_tasks}")
self.execute_task(func, *args, **kwargs)
else:
print(
f"enqueue task: {func.__name__}, current_tasks: {self.current_tasks}"
)
self.enqueue({"func": func, "args": args, "kwargs": kwargs})
def execute_task(self, func: Callable, *args: Any, **kwargs: Any):
thread = threading.Thread(
target=self.run_task, args=(func, *args), kwargs=kwargs
)
thread.start()
def run_task(self, func: Callable, *args: Any, **kwargs: Any):
try:
with self.lock:
self.current_tasks += 1
func(*args, **kwargs) # call the function here, passing *args and **kwargs.
finally:
self.task_done()
def check_queue(self):
with self.lock:
if (
self.current_tasks < self.max_concurrent_tasks
and not self.is_queue_empty()
):
task_info = self.dequeue()
func = task_info["func"]
args = task_info.get("args", ())
kwargs = task_info.get("kwargs", {})
self.execute_task(func, *args, **kwargs)
def task_done(self):
with self.lock:
self.current_tasks -= 1
self.check_queue()
def enqueue(self, task: Dict):
raise NotImplementedError()
def dequeue(self):
raise NotImplementedError()
def is_queue_empty(self):
raise NotImplementedError()

View File

@@ -0,0 +1,18 @@
from queue import Queue
from typing import Dict
from app.controllers.manager.base_manager import TaskManager
class InMemoryTaskManager(TaskManager):
def create_queue(self):
return Queue()
def enqueue(self, task: Dict):
self.queue.put(task)
def dequeue(self):
return self.queue.get()
def is_queue_empty(self):
return self.queue.empty()

View File

@@ -0,0 +1,56 @@
import json
from typing import Dict
import redis
from app.controllers.manager.base_manager import TaskManager
from app.models.schema import VideoParams
from app.services import task as tm
FUNC_MAP = {
"start": tm.start,
# 'start_test': tm.start_test
}
class RedisTaskManager(TaskManager):
def __init__(self, max_concurrent_tasks: int, redis_url: str):
self.redis_client = redis.Redis.from_url(redis_url)
super().__init__(max_concurrent_tasks)
def create_queue(self):
return "task_queue"
def enqueue(self, task: Dict):
task_with_serializable_params = task.copy()
if "params" in task["kwargs"] and isinstance(
task["kwargs"]["params"], VideoParams
):
task_with_serializable_params["kwargs"]["params"] = task["kwargs"][
"params"
].dict()
# 将函数对象转换为其名称
task_with_serializable_params["func"] = task["func"].__name__
self.redis_client.rpush(self.queue, json.dumps(task_with_serializable_params))
def dequeue(self):
task_json = self.redis_client.lpop(self.queue)
if task_json:
task_info = json.loads(task_json)
# 将函数名称转换回函数对象
task_info["func"] = FUNC_MAP[task_info["func"]]
if "params" in task_info["kwargs"] and isinstance(
task_info["kwargs"]["params"], dict
):
task_info["kwargs"]["params"] = VideoParams(
**task_info["kwargs"]["params"]
)
return task_info
return None
def is_queue_empty(self):
return self.redis_client.llen(self.queue) == 0

View File

@@ -1,9 +1,13 @@
from fastapi import APIRouter from fastapi import APIRouter, Request
from fastapi import Request
router = APIRouter() router = APIRouter()
@router.get("/ping", tags=["Health Check"], description="检查服务可用性", response_description="pong") @router.get(
"/ping",
tags=["Health Check"],
description="检查服务可用性",
response_description="pong",
)
def ping(request: Request) -> str: def ping(request: Request) -> str:
return "pong" return "pong"

View File

@@ -1,10 +1,10 @@
from fastapi import APIRouter, Depends from fastapi import APIRouter
def new_router(dependencies=None): def new_router(dependencies=None):
router = APIRouter() router = APIRouter()
router.tags = ['V1'] router.tags = ["V1"]
router.prefix = '/api/v1' router.prefix = "/api/v1"
# 将认证依赖项应用于所有路由 # 将认证依赖项应用于所有路由
if dependencies: if dependencies:
router.dependencies = dependencies router.dependencies = dependencies

View File

@@ -1,31 +1,45 @@
from fastapi import Request from fastapi import Request
from app.controllers.v1.base import new_router from app.controllers.v1.base import new_router
from app.models.schema import VideoScriptResponse, VideoScriptRequest, VideoTermsResponse, VideoTermsRequest from app.models.schema import (
VideoScriptRequest,
VideoScriptResponse,
VideoTermsRequest,
VideoTermsResponse,
)
from app.services import llm from app.services import llm
from app.utils import utils from app.utils import utils
# 认证依赖项 # authentication dependency
# router = new_router(dependencies=[Depends(base.verify_token)]) # router = new_router(dependencies=[Depends(base.verify_token)])
router = new_router() router = new_router()
@router.post("/scripts", response_model=VideoScriptResponse, summary="Create a script for the video") @router.post(
"/scripts",
response_model=VideoScriptResponse,
summary="Create a script for the video",
)
def generate_video_script(request: Request, body: VideoScriptRequest): def generate_video_script(request: Request, body: VideoScriptRequest):
video_script = llm.generate_script(video_subject=body.video_subject, video_script = llm.generate_script(
language=body.video_language, video_subject=body.video_subject,
paragraph_number=body.paragraph_number) language=body.video_language,
response = { paragraph_number=body.paragraph_number,
"video_script": video_script )
} response = {"video_script": video_script}
return utils.get_response(200, response) return utils.get_response(200, response)
@router.post("/terms", response_model=VideoTermsResponse, summary="Generate video terms based on the video script") @router.post(
"/terms",
response_model=VideoTermsResponse,
summary="Generate video terms based on the video script",
)
def generate_video_terms(request: Request, body: VideoTermsRequest): def generate_video_terms(request: Request, body: VideoTermsRequest):
video_terms = llm.generate_terms(video_subject=body.video_subject, video_terms = llm.generate_terms(
video_script=body.video_script, video_subject=body.video_subject,
amount=body.amount) video_script=body.video_script,
response = { amount=body.amount,
"video_terms": video_terms )
} response = {"video_terms": video_terms}
return utils.get_response(200, response) return utils.get_response(200, response)

View File

@@ -1,47 +1,124 @@
import os
import glob import glob
import os
import pathlib
import shutil import shutil
from typing import Union
from fastapi import Request, Depends, Path, BackgroundTasks, UploadFile from fastapi import BackgroundTasks, Depends, Path, Request, UploadFile
from fastapi.params import File from fastapi.params import File
from fastapi.responses import FileResponse, StreamingResponse
from loguru import logger from loguru import logger
from app.config import config from app.config import config
from app.controllers import base from app.controllers import base
from app.controllers.manager.memory_manager import InMemoryTaskManager
from app.controllers.manager.redis_manager import RedisTaskManager
from app.controllers.v1.base import new_router from app.controllers.v1.base import new_router
from app.models.exception import HttpException from app.models.exception import HttpException
from app.models.schema import TaskVideoRequest, TaskQueryResponse, TaskResponse, TaskQueryRequest, \ from app.models.schema import (
BgmUploadResponse, BgmRetrieveResponse, TaskDeletionResponse AudioRequest,
from app.services import task as tm BgmRetrieveResponse,
BgmUploadResponse,
SubtitleRequest,
TaskDeletionResponse,
TaskQueryRequest,
TaskQueryResponse,
TaskResponse,
TaskVideoRequest,
)
from app.services import state as sm from app.services import state as sm
from app.services import task as tm
from app.utils import utils from app.utils import utils
# 认证依赖项 # 认证依赖项
# router = new_router(dependencies=[Depends(base.verify_token)]) # router = new_router(dependencies=[Depends(base.verify_token)])
router = new_router() router = new_router()
_enable_redis = config.app.get("enable_redis", False)
_redis_host = config.app.get("redis_host", "localhost")
_redis_port = config.app.get("redis_port", 6379)
_redis_db = config.app.get("redis_db", 0)
_redis_password = config.app.get("redis_password", None)
_max_concurrent_tasks = config.app.get("max_concurrent_tasks", 5)
redis_url = f"redis://:{_redis_password}@{_redis_host}:{_redis_port}/{_redis_db}"
# 根据配置选择合适的任务管理器
if _enable_redis:
task_manager = RedisTaskManager(
max_concurrent_tasks=_max_concurrent_tasks, redis_url=redis_url
)
else:
task_manager = InMemoryTaskManager(max_concurrent_tasks=_max_concurrent_tasks)
@router.post("/videos", response_model=TaskResponse, summary="Generate a short video") @router.post("/videos", response_model=TaskResponse, summary="Generate a short video")
def create_video(background_tasks: BackgroundTasks, request: Request, body: TaskVideoRequest): def create_video(
background_tasks: BackgroundTasks, request: Request, body: TaskVideoRequest
):
return create_task(request, body, stop_at="video")
@router.post("/subtitle", response_model=TaskResponse, summary="Generate subtitle only")
def create_subtitle(
background_tasks: BackgroundTasks, request: Request, body: SubtitleRequest
):
return create_task(request, body, stop_at="subtitle")
@router.post("/audio", response_model=TaskResponse, summary="Generate audio only")
def create_audio(
background_tasks: BackgroundTasks, request: Request, body: AudioRequest
):
return create_task(request, body, stop_at="audio")
def create_task(
request: Request,
body: Union[TaskVideoRequest, SubtitleRequest, AudioRequest],
stop_at: str,
):
task_id = utils.get_uuid() task_id = utils.get_uuid()
request_id = base.get_task_id(request) request_id = base.get_task_id(request)
try: try:
task = { task = {
"task_id": task_id, "task_id": task_id,
"request_id": request_id, "request_id": request_id,
"params": body.dict(), "params": body.model_dump(),
} }
sm.state.update_task(task_id) sm.state.update_task(task_id)
background_tasks.add_task(tm.start, task_id=task_id, params=body) task_manager.add_task(tm.start, task_id=task_id, params=body, stop_at=stop_at)
logger.success(f"video created: {utils.to_json(task)}") logger.success(f"Task created: {utils.to_json(task)}")
return utils.get_response(200, task) return utils.get_response(200, task)
except ValueError as e: except ValueError as e:
raise HttpException(task_id=task_id, status_code=400, message=f"{request_id}: {str(e)}") raise HttpException(
task_id=task_id, status_code=400, message=f"{request_id}: {str(e)}"
)
from fastapi import Query
@router.get("/tasks", response_model=TaskQueryResponse, summary="Get all tasks")
def get_all_tasks(request: Request, page: int = Query(1, ge=1), page_size: int = Query(10, ge=1)):
request_id = base.get_task_id(request)
tasks, total = sm.state.get_all_tasks(page, page_size)
response = {
"tasks": tasks,
"total": total,
"page": page,
"page_size": page_size,
}
return utils.get_response(200, response)
@router.get("/tasks/{task_id}", response_model=TaskQueryResponse, summary="Query task status")
def get_task(request: Request, task_id: str = Path(..., description="Task ID"), @router.get(
query: TaskQueryRequest = Depends()): "/tasks/{task_id}", response_model=TaskQueryResponse, summary="Query task status"
)
def get_task(
request: Request,
task_id: str = Path(..., description="Task ID"),
query: TaskQueryRequest = Depends(),
):
endpoint = config.app.get("endpoint", "") endpoint = config.app.get("endpoint", "")
if not endpoint: if not endpoint:
endpoint = str(request.base_url) endpoint = str(request.base_url)
@@ -74,11 +151,17 @@ def get_task(request: Request, task_id: str = Path(..., description="Task ID"),
task["combined_videos"] = urls task["combined_videos"] = urls
return utils.get_response(200, task) return utils.get_response(200, task)
raise HttpException(task_id=task_id, status_code=404, message=f"{request_id}: task not found") raise HttpException(
task_id=task_id, status_code=404, message=f"{request_id}: task not found"
)
@router.delete("/tasks/{task_id}", response_model=TaskDeletionResponse, summary="Delete a generated short video task") @router.delete(
def create_video(request: Request, task_id: str = Path(..., description="Task ID")): "/tasks/{task_id}",
response_model=TaskDeletionResponse,
summary="Delete a generated short video task",
)
def delete_video(request: Request, task_id: str = Path(..., description="Task ID")):
request_id = base.get_task_id(request) request_id = base.get_task_id(request)
task = sm.state.get_task(task_id) task = sm.state.get_task(task_id)
if task: if task:
@@ -89,34 +172,42 @@ def create_video(request: Request, task_id: str = Path(..., description="Task ID
sm.state.delete_task(task_id) sm.state.delete_task(task_id)
logger.success(f"video deleted: {utils.to_json(task)}") logger.success(f"video deleted: {utils.to_json(task)}")
return utils.get_response(200, task) return utils.get_response(200)
raise HttpException(task_id=task_id, status_code=404, message=f"{request_id}: task not found") raise HttpException(
task_id=task_id, status_code=404, message=f"{request_id}: task not found"
)
@router.get("/musics", response_model=BgmRetrieveResponse, summary="Retrieve local BGM files") @router.get(
"/musics", response_model=BgmRetrieveResponse, summary="Retrieve local BGM files"
)
def get_bgm_list(request: Request): def get_bgm_list(request: Request):
suffix = "*.mp3" suffix = "*.mp3"
song_dir = utils.song_dir() song_dir = utils.song_dir()
files = glob.glob(os.path.join(song_dir, suffix)) files = glob.glob(os.path.join(song_dir, suffix))
bgm_list = [] bgm_list = []
for file in files: for file in files:
bgm_list.append({ bgm_list.append(
"name": os.path.basename(file), {
"size": os.path.getsize(file), "name": os.path.basename(file),
"file": file, "size": os.path.getsize(file),
}) "file": file,
response = { }
"files": bgm_list )
} response = {"files": bgm_list}
return utils.get_response(200, response) return utils.get_response(200, response)
@router.post("/musics", response_model=BgmUploadResponse, summary="Upload the BGM file to the songs directory") @router.post(
"/musics",
response_model=BgmUploadResponse,
summary="Upload the BGM file to the songs directory",
)
def upload_bgm_file(request: Request, file: UploadFile = File(...)): def upload_bgm_file(request: Request, file: UploadFile = File(...)):
request_id = base.get_task_id(request) request_id = base.get_task_id(request)
# check file ext # check file ext
if file.filename.endswith('mp3'): if file.filename.endswith("mp3"):
song_dir = utils.song_dir() song_dir = utils.song_dir()
save_path = os.path.join(song_dir, file.filename) save_path = os.path.join(song_dir, file.filename)
# save file # save file
@@ -124,9 +215,73 @@ def upload_bgm_file(request: Request, file: UploadFile = File(...)):
# If the file already exists, it will be overwritten # If the file already exists, it will be overwritten
file.file.seek(0) file.file.seek(0)
buffer.write(file.file.read()) buffer.write(file.file.read())
response = { response = {"file": save_path}
"file": save_path
}
return utils.get_response(200, response) return utils.get_response(200, response)
raise HttpException('', status_code=400, message=f"{request_id}: Only *.mp3 files can be uploaded") raise HttpException(
"", status_code=400, message=f"{request_id}: Only *.mp3 files can be uploaded"
)
@router.get("/stream/{file_path:path}")
async def stream_video(request: Request, file_path: str):
tasks_dir = utils.task_dir()
video_path = os.path.join(tasks_dir, file_path)
range_header = request.headers.get("Range")
video_size = os.path.getsize(video_path)
start, end = 0, video_size - 1
length = video_size
if range_header:
range_ = range_header.split("bytes=")[1]
start, end = [int(part) if part else None for part in range_.split("-")]
if start is None:
start = video_size - end
end = video_size - 1
if end is None:
end = video_size - 1
length = end - start + 1
def file_iterator(file_path, offset=0, bytes_to_read=None):
with open(file_path, "rb") as f:
f.seek(offset, os.SEEK_SET)
remaining = bytes_to_read or video_size
while remaining > 0:
bytes_to_read = min(4096, remaining)
data = f.read(bytes_to_read)
if not data:
break
remaining -= len(data)
yield data
response = StreamingResponse(
file_iterator(video_path, start, length), media_type="video/mp4"
)
response.headers["Content-Range"] = f"bytes {start}-{end}/{video_size}"
response.headers["Accept-Ranges"] = "bytes"
response.headers["Content-Length"] = str(length)
response.status_code = 206 # Partial Content
return response
@router.get("/download/{file_path:path}")
async def download_video(_: Request, file_path: str):
"""
download video
:param _: Request request
:param file_path: video file path, eg: /cd1727ed-3473-42a2-a7da-4faafafec72b/final-1.mp4
:return: video file
"""
tasks_dir = utils.task_dir()
video_path = os.path.join(tasks_dir, file_path)
file_path = pathlib.Path(video_path)
filename = file_path.stem
extension = file_path.suffix
headers = {"Content-Disposition": f"attachment; filename={filename}{extension}"}
return FileResponse(
path=video_path,
headers=headers,
filename=f"{filename}{extension}",
media_type=f"video/{extension[1:]}",
)

View File

@@ -1,8 +1,25 @@
PUNCTUATIONS = [ PUNCTUATIONS = [
"?", ",", ".", "", ";", ":", "!", "", "?",
"", "", "", "", "", "", "", "...", ",",
".",
"",
";",
":",
"!",
"",
"",
"",
"",
"",
"",
"",
"",
"...",
] ]
TASK_STATE_FAILED = -1 TASK_STATE_FAILED = -1
TASK_STATE_COMPLETE = 1 TASK_STATE_COMPLETE = 1
TASK_STATE_PROCESSING = 4 TASK_STATE_PROCESSING = 4
FILE_TYPE_VIDEOS = ["mp4", "mov", "mkv", "webm"]
FILE_TYPE_IMAGES = ["jpg", "jpeg", "png", "bmp"]

View File

@@ -5,16 +5,18 @@ from loguru import logger
class HttpException(Exception): class HttpException(Exception):
def __init__(self, task_id: str, status_code: int, message: str = '', data: Any = None): def __init__(
self, task_id: str, status_code: int, message: str = "", data: Any = None
):
self.message = message self.message = message
self.status_code = status_code self.status_code = status_code
self.data = data self.data = data
# 获取异常堆栈信息 # Retrieve the exception stack trace information.
tb_str = traceback.format_exc().strip() tb_str = traceback.format_exc().strip()
if not tb_str or tb_str == "NoneType: None": if not tb_str or tb_str == "NoneType: None":
msg = f'HttpException: {status_code}, {task_id}, {message}' msg = f"HttpException: {status_code}, {task_id}, {message}"
else: else:
msg = f'HttpException: {status_code}, {task_id}, {message}\n{tb_str}' msg = f"HttpException: {status_code}, {task_id}, {message}\n{tb_str}"
if status_code == 400: if status_code == 400:
logger.warning(msg) logger.warning(msg)

View File

@@ -1,11 +1,16 @@
from enum import Enum
from typing import Any, Optional
from pydantic import BaseModel
import warnings import warnings
from enum import Enum
from typing import Any, List, Optional, Union
import pydantic
from pydantic import BaseModel
# 忽略 Pydantic 的特定警告 # 忽略 Pydantic 的特定警告
warnings.filterwarnings("ignore", category=UserWarning, message="Field name.*shadows an attribute in parent.*") warnings.filterwarnings(
"ignore",
category=UserWarning,
message="Field name.*shadows an attribute in parent.*",
)
class VideoConcatMode(str, Enum): class VideoConcatMode(str, Enum):
@@ -13,6 +18,15 @@ class VideoConcatMode(str, Enum):
sequential = "sequential" sequential = "sequential"
class VideoTransitionMode(str, Enum):
none = None
shuffle = "Shuffle"
fade_in = "FadeIn"
fade_out = "FadeOut"
slide_in = "SlideIn"
slide_out = "SlideOut"
class VideoAspect(str, Enum): class VideoAspect(str, Enum):
landscape = "16:9" landscape = "16:9"
portrait = "9:16" portrait = "9:16"
@@ -28,52 +42,18 @@ class VideoAspect(str, Enum):
return 1080, 1920 return 1080, 1920
class _Config:
arbitrary_types_allowed = True
@pydantic.dataclasses.dataclass(config=_Config)
class MaterialInfo: class MaterialInfo:
provider: str = "pexels" provider: str = "pexels"
url: str = "" url: str = ""
duration: int = 0 duration: int = 0
# VoiceNames = [ class VideoParams(BaseModel):
# # zh-CN
# "female-zh-CN-XiaoxiaoNeural",
# "female-zh-CN-XiaoyiNeural",
# "female-zh-CN-liaoning-XiaobeiNeural",
# "female-zh-CN-shaanxi-XiaoniNeural",
#
# "male-zh-CN-YunjianNeural",
# "male-zh-CN-YunxiNeural",
# "male-zh-CN-YunxiaNeural",
# "male-zh-CN-YunyangNeural",
#
# # "female-zh-HK-HiuGaaiNeural",
# # "female-zh-HK-HiuMaanNeural",
# # "male-zh-HK-WanLungNeural",
# #
# # "female-zh-TW-HsiaoChenNeural",
# # "female-zh-TW-HsiaoYuNeural",
# # "male-zh-TW-YunJheNeural",
#
# # en-US
#
# "female-en-US-AnaNeural",
# "female-en-US-AriaNeural",
# "female-en-US-AvaNeural",
# "female-en-US-EmmaNeural",
# "female-en-US-JennyNeural",
# "female-en-US-MichelleNeural",
#
# "male-en-US-AndrewNeural",
# "male-en-US-BrianNeural",
# "male-en-US-ChristopherNeural",
# "male-en-US-EricNeural",
# "male-en-US-GuyNeural",
# "male-en-US-RogerNeural",
# "male-en-US-SteffanNeural",
# ]
class VideoParams:
""" """
{ {
"video_subject": "", "video_subject": "",
@@ -87,27 +67,36 @@ class VideoParams:
"stroke_width": 1.5 "stroke_width": 1.5
} }
""" """
video_subject: str video_subject: str
video_script: str = "" # 用于生成视频的脚本 video_script: str = "" # Script used to generate the video
video_terms: Optional[str | list] = None # 用于生成视频的关键词 video_terms: Optional[str | list] = None # Keywords used to generate the video
video_aspect: Optional[VideoAspect] = VideoAspect.portrait.value video_aspect: Optional[VideoAspect] = VideoAspect.portrait.value
video_concat_mode: Optional[VideoConcatMode] = VideoConcatMode.random.value video_concat_mode: Optional[VideoConcatMode] = VideoConcatMode.random.value
video_transition_mode: Optional[VideoTransitionMode] = None
video_clip_duration: Optional[int] = 5 video_clip_duration: Optional[int] = 5
video_count: Optional[int] = 1 video_count: Optional[int] = 1
video_source: Optional[str] = "pexels"
video_materials: Optional[List[MaterialInfo]] = (
None # Materials used to generate the video
)
video_language: Optional[str] = "" # auto detect video_language: Optional[str] = "" # auto detect
voice_name: Optional[str] = "" voice_name: Optional[str] = ""
voice_volume: Optional[float] = 1.0 voice_volume: Optional[float] = 1.0
voice_rate: Optional[float] = 1.0
bgm_type: Optional[str] = "random" bgm_type: Optional[str] = "random"
bgm_file: Optional[str] = "" bgm_file: Optional[str] = ""
bgm_volume: Optional[float] = 0.2 bgm_volume: Optional[float] = 0.2
subtitle_enabled: Optional[bool] = True subtitle_enabled: Optional[bool] = True
subtitle_position: Optional[str] = "bottom" # top, bottom, center subtitle_position: Optional[str] = "bottom" # top, bottom, center
custom_position: float = 70.0
font_name: Optional[str] = "STHeitiMedium.ttc" font_name: Optional[str] = "STHeitiMedium.ttc"
text_fore_color: Optional[str] = "#FFFFFF" text_fore_color: Optional[str] = "#FFFFFF"
text_background_color: Optional[str] = "transparent" text_background_color: Union[bool, str] = True
font_size: int = 60 font_size: int = 60
stroke_color: Optional[str] = "#000000" stroke_color: Optional[str] = "#000000"
@@ -116,6 +105,38 @@ class VideoParams:
paragraph_number: Optional[int] = 1 paragraph_number: Optional[int] = 1
class SubtitleRequest(BaseModel):
video_script: str
video_language: Optional[str] = ""
voice_name: Optional[str] = "zh-CN-XiaoxiaoNeural-Female"
voice_volume: Optional[float] = 1.0
voice_rate: Optional[float] = 1.2
bgm_type: Optional[str] = "random"
bgm_file: Optional[str] = ""
bgm_volume: Optional[float] = 0.2
subtitle_position: Optional[str] = "bottom"
font_name: Optional[str] = "STHeitiMedium.ttc"
text_fore_color: Optional[str] = "#FFFFFF"
text_background_color: Union[bool, str] = True
font_size: int = 60
stroke_color: Optional[str] = "#000000"
stroke_width: float = 1.5
video_source: Optional[str] = "local"
subtitle_enabled: Optional[str] = "true"
class AudioRequest(BaseModel):
video_script: str
video_language: Optional[str] = ""
voice_name: Optional[str] = "zh-CN-XiaoxiaoNeural-Female"
voice_volume: Optional[float] = 1.0
voice_rate: Optional[float] = 1.2
bgm_type: Optional[str] = "random"
bgm_file: Optional[str] = ""
bgm_volume: Optional[float] = 0.2
video_source: Optional[str] = "local"
class VideoScriptParams: class VideoScriptParams:
""" """
{ {
@@ -124,6 +145,7 @@ class VideoScriptParams:
"paragraph_number": 1 "paragraph_number": 1
} }
""" """
video_subject: Optional[str] = "春天的花海" video_subject: Optional[str] = "春天的花海"
video_language: Optional[str] = "" video_language: Optional[str] = ""
paragraph_number: Optional[int] = 1 paragraph_number: Optional[int] = 1
@@ -137,14 +159,17 @@ class VideoTermsParams:
"amount": 5 "amount": 5
} }
""" """
video_subject: Optional[str] = "春天的花海" video_subject: Optional[str] = "春天的花海"
video_script: Optional[str] = "春天的花海,如诗如画般展现在眼前。万物复苏的季节里,大地披上了一袭绚丽多彩的盛装。金黄的迎春、粉嫩的樱花、洁白的梨花、艳丽的郁金香……" video_script: Optional[str] = (
"春天的花海,如诗如画般展现在眼前。万物复苏的季节里,大地披上了一袭绚丽多彩的盛装。金黄的迎春、粉嫩的樱花、洁白的梨花、艳丽的郁金香……"
)
amount: Optional[int] = 5 amount: Optional[int] = 5
class BaseResponse(BaseModel): class BaseResponse(BaseModel):
status: int = 200 status: int = 200
message: Optional[str] = 'success' message: Optional[str] = "success"
data: Any = None data: Any = None
@@ -179,9 +204,7 @@ class TaskResponse(BaseResponse):
"example": { "example": {
"status": 200, "status": 200,
"message": "success", "message": "success",
"data": { "data": {"task_id": "6c85c8cc-a77a-42b9-bc30-947815aa0558"},
"task_id": "6c85c8cc-a77a-42b9-bc30-947815aa0558"
}
}, },
} }
@@ -200,8 +223,8 @@ class TaskQueryResponse(BaseResponse):
], ],
"combined_videos": [ "combined_videos": [
"http://127.0.0.1:8080/tasks/6c85c8cc-a77a-42b9-bc30-947815aa0558/combined-1.mp4" "http://127.0.0.1:8080/tasks/6c85c8cc-a77a-42b9-bc30-947815aa0558/combined-1.mp4"
] ],
} },
}, },
} }
@@ -220,8 +243,8 @@ class TaskDeletionResponse(BaseResponse):
], ],
"combined_videos": [ "combined_videos": [
"http://127.0.0.1:8080/tasks/6c85c8cc-a77a-42b9-bc30-947815aa0558/combined-1.mp4" "http://127.0.0.1:8080/tasks/6c85c8cc-a77a-42b9-bc30-947815aa0558/combined-1.mp4"
] ],
} },
}, },
} }
@@ -234,7 +257,7 @@ class VideoScriptResponse(BaseResponse):
"message": "success", "message": "success",
"data": { "data": {
"video_script": "春天的花海,是大自然的一幅美丽画卷。在这个季节里,大地复苏,万物生长,花朵争相绽放,形成了一片五彩斑斓的花海..." "video_script": "春天的花海,是大自然的一幅美丽画卷。在这个季节里,大地复苏,万物生长,花朵争相绽放,形成了一片五彩斑斓的花海..."
} },
}, },
} }
@@ -245,9 +268,7 @@ class VideoTermsResponse(BaseResponse):
"example": { "example": {
"status": 200, "status": 200,
"message": "success", "message": "success",
"data": { "data": {"video_terms": ["sky", "tree"]},
"video_terms": ["sky", "tree"]
}
}, },
} }
@@ -263,10 +284,10 @@ class BgmRetrieveResponse(BaseResponse):
{ {
"name": "output013.mp3", "name": "output013.mp3",
"size": 1891269, "size": 1891269,
"file": "/MoneyPrinterTurbo/resource/songs/output013.mp3" "file": "/MoneyPrinterTurbo/resource/songs/output013.mp3",
} }
] ]
} },
}, },
} }
@@ -277,8 +298,6 @@ class BgmUploadResponse(BaseResponse):
"example": { "example": {
"status": 200, "status": 200,
"message": "success", "message": "success",
"data": { "data": {"file": "/MoneyPrinterTurbo/resource/songs/example.mp3"},
"file": "/MoneyPrinterTurbo/resource/songs/example.mp3"
}
}, },
} }

View File

@@ -6,9 +6,10 @@ Resources:
1. https://fastapi.tiangolo.com/tutorial/bigger-applications 1. https://fastapi.tiangolo.com/tutorial/bigger-applications
""" """
from fastapi import APIRouter from fastapi import APIRouter
from app.controllers.v1 import video, llm from app.controllers.v1 import llm, video
root_api_router = APIRouter() root_api_router = APIRouter()
# v1 # v1

View File

@@ -1,154 +1,267 @@
import json
import logging import logging
import re import re
import json
from typing import List from typing import List
import g4f
from loguru import logger from loguru import logger
from openai import OpenAI from openai import AzureOpenAI, OpenAI
from openai import AzureOpenAI
from openai.types.chat import ChatCompletion from openai.types.chat import ChatCompletion
from app.config import config from app.config import config
_max_retries = 5
def _generate_response(prompt: str) -> str: def _generate_response(prompt: str) -> str:
content = "" try:
llm_provider = config.app.get("llm_provider", "openai") content = ""
logger.info(f"llm provider: {llm_provider}") llm_provider = config.app.get("llm_provider", "openai")
if llm_provider == "g4f": logger.info(f"llm provider: {llm_provider}")
model_name = config.app.get("g4f_model_name", "") if llm_provider == "g4f":
if not model_name: model_name = config.app.get("g4f_model_name", "")
model_name = "gpt-3.5-turbo-16k-0613" if not model_name:
import g4f model_name = "gpt-3.5-turbo-16k-0613"
content = g4f.ChatCompletion.create( content = g4f.ChatCompletion.create(
model=model_name,
messages=[{"role": "user", "content": prompt}],
)
else:
api_version = "" # for azure
if llm_provider == "moonshot":
api_key = config.app.get("moonshot_api_key")
model_name = config.app.get("moonshot_model_name")
base_url = "https://api.moonshot.cn/v1"
elif llm_provider == "ollama":
# api_key = config.app.get("openai_api_key")
api_key = "ollama" # any string works but you are required to have one
model_name = config.app.get("ollama_model_name")
base_url = config.app.get("ollama_base_url", "")
if not base_url:
base_url = "http://localhost:11434/v1"
elif llm_provider == "openai":
api_key = config.app.get("openai_api_key")
model_name = config.app.get("openai_model_name")
base_url = config.app.get("openai_base_url", "")
if not base_url:
base_url = "https://api.openai.com/v1"
elif llm_provider == "oneapi":
api_key = config.app.get("oneapi_api_key")
model_name = config.app.get("oneapi_model_name")
base_url = config.app.get("oneapi_base_url", "")
elif llm_provider == "azure":
api_key = config.app.get("azure_api_key")
model_name = config.app.get("azure_model_name")
base_url = config.app.get("azure_base_url", "")
api_version = config.app.get("azure_api_version", "2024-02-15-preview")
elif llm_provider == "gemini":
api_key = config.app.get("gemini_api_key")
model_name = config.app.get("gemini_model_name")
base_url = "***"
elif llm_provider == "qwen":
api_key = config.app.get("qwen_api_key")
model_name = config.app.get("qwen_model_name")
base_url = "***"
else:
raise ValueError("llm_provider is not set, please set it in the config.toml file.")
if not api_key:
raise ValueError(f"{llm_provider}: api_key is not set, please set it in the config.toml file.")
if not model_name:
raise ValueError(f"{llm_provider}: model_name is not set, please set it in the config.toml file.")
if not base_url:
raise ValueError(f"{llm_provider}: base_url is not set, please set it in the config.toml file.")
if llm_provider == "qwen":
import dashscope
dashscope.api_key = api_key
response = dashscope.Generation.call(
model=model_name, model=model_name,
messages=[{"role": "user", "content": prompt}] messages=[{"role": "user", "content": prompt}],
)
content = response["output"]["text"]
return content.replace("\n", "")
if llm_provider == "gemini":
import google.generativeai as genai
genai.configure(api_key=api_key)
generation_config = {
"temperature": 0.5,
"top_p": 1,
"top_k": 1,
"max_output_tokens": 2048,
}
safety_settings = [
{
"category": "HARM_CATEGORY_HARASSMENT",
"threshold": "BLOCK_ONLY_HIGH"
},
{
"category": "HARM_CATEGORY_HATE_SPEECH",
"threshold": "BLOCK_ONLY_HIGH"
},
{
"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT",
"threshold": "BLOCK_ONLY_HIGH"
},
{
"category": "HARM_CATEGORY_DANGEROUS_CONTENT",
"threshold": "BLOCK_ONLY_HIGH"
},
]
model = genai.GenerativeModel(model_name=model_name,
generation_config=generation_config,
safety_settings=safety_settings)
convo = model.start_chat(history=[])
convo.send_message(prompt)
return convo.last.text
if llm_provider == "azure":
client = AzureOpenAI(
api_key=api_key,
api_version=api_version,
azure_endpoint=base_url,
) )
else: else:
client = OpenAI( api_version = "" # for azure
api_key=api_key, if llm_provider == "moonshot":
base_url=base_url, api_key = config.app.get("moonshot_api_key")
) model_name = config.app.get("moonshot_model_name")
base_url = "https://api.moonshot.cn/v1"
elif llm_provider == "ollama":
# api_key = config.app.get("openai_api_key")
api_key = "ollama" # any string works but you are required to have one
model_name = config.app.get("ollama_model_name")
base_url = config.app.get("ollama_base_url", "")
if not base_url:
base_url = "http://localhost:11434/v1"
elif llm_provider == "openai":
api_key = config.app.get("openai_api_key")
model_name = config.app.get("openai_model_name")
base_url = config.app.get("openai_base_url", "")
if not base_url:
base_url = "https://api.openai.com/v1"
elif llm_provider == "oneapi":
api_key = config.app.get("oneapi_api_key")
model_name = config.app.get("oneapi_model_name")
base_url = config.app.get("oneapi_base_url", "")
elif llm_provider == "azure":
api_key = config.app.get("azure_api_key")
model_name = config.app.get("azure_model_name")
base_url = config.app.get("azure_base_url", "")
api_version = config.app.get("azure_api_version", "2024-02-15-preview")
elif llm_provider == "gemini":
api_key = config.app.get("gemini_api_key")
model_name = config.app.get("gemini_model_name")
base_url = "***"
elif llm_provider == "qwen":
api_key = config.app.get("qwen_api_key")
model_name = config.app.get("qwen_model_name")
base_url = "***"
elif llm_provider == "cloudflare":
api_key = config.app.get("cloudflare_api_key")
model_name = config.app.get("cloudflare_model_name")
account_id = config.app.get("cloudflare_account_id")
base_url = "***"
elif llm_provider == "deepseek":
api_key = config.app.get("deepseek_api_key")
model_name = config.app.get("deepseek_model_name")
base_url = config.app.get("deepseek_base_url")
if not base_url:
base_url = "https://api.deepseek.com"
elif llm_provider == "ernie":
api_key = config.app.get("ernie_api_key")
secret_key = config.app.get("ernie_secret_key")
base_url = config.app.get("ernie_base_url")
model_name = "***"
if not secret_key:
raise ValueError(
f"{llm_provider}: secret_key is not set, please set it in the config.toml file."
)
else:
raise ValueError(
"llm_provider is not set, please set it in the config.toml file."
)
response = client.chat.completions.create( if not api_key:
model=model_name, raise ValueError(
messages=[{"role": "user", "content": prompt}] f"{llm_provider}: api_key is not set, please set it in the config.toml file."
) )
if response: if not model_name:
if isinstance(response, ChatCompletion): raise ValueError(
content = response.choices[0].message.content f"{llm_provider}: model_name is not set, please set it in the config.toml file."
)
if not base_url:
raise ValueError(
f"{llm_provider}: base_url is not set, please set it in the config.toml file."
)
if llm_provider == "qwen":
import dashscope
from dashscope.api_entities.dashscope_response import GenerationResponse
dashscope.api_key = api_key
response = dashscope.Generation.call(
model=model_name, messages=[{"role": "user", "content": prompt}]
)
if response:
if isinstance(response, GenerationResponse):
status_code = response.status_code
if status_code != 200:
raise Exception(
f'[{llm_provider}] returned an error response: "{response}"'
)
content = response["output"]["text"]
return content.replace("\n", "")
else:
raise Exception(
f'[{llm_provider}] returned an invalid response: "{response}"'
)
else:
raise Exception(f"[{llm_provider}] returned an empty response")
if llm_provider == "gemini":
import google.generativeai as genai
genai.configure(api_key=api_key, transport="rest")
generation_config = {
"temperature": 0.5,
"top_p": 1,
"top_k": 1,
"max_output_tokens": 2048,
}
safety_settings = [
{
"category": "HARM_CATEGORY_HARASSMENT",
"threshold": "BLOCK_ONLY_HIGH",
},
{
"category": "HARM_CATEGORY_HATE_SPEECH",
"threshold": "BLOCK_ONLY_HIGH",
},
{
"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT",
"threshold": "BLOCK_ONLY_HIGH",
},
{
"category": "HARM_CATEGORY_DANGEROUS_CONTENT",
"threshold": "BLOCK_ONLY_HIGH",
},
]
model = genai.GenerativeModel(
model_name=model_name,
generation_config=generation_config,
safety_settings=safety_settings,
)
try:
response = model.generate_content(prompt)
candidates = response.candidates
generated_text = candidates[0].content.parts[0].text
except (AttributeError, IndexError) as e:
print("Gemini Error:", e)
return generated_text
if llm_provider == "cloudflare":
import requests
response = requests.post(
f"https://api.cloudflare.com/client/v4/accounts/{account_id}/ai/run/{model_name}",
headers={"Authorization": f"Bearer {api_key}"},
json={
"messages": [
{
"role": "system",
"content": "You are a friendly assistant",
},
{"role": "user", "content": prompt},
]
},
)
result = response.json()
logger.info(result)
return result["result"]["response"]
if llm_provider == "ernie":
import requests
params = {
"grant_type": "client_credentials",
"client_id": api_key,
"client_secret": secret_key,
}
access_token = (
requests.post(
"https://aip.baidubce.com/oauth/2.0/token", params=params
)
.json()
.get("access_token")
)
url = f"{base_url}?access_token={access_token}"
payload = json.dumps(
{
"messages": [{"role": "user", "content": prompt}],
"temperature": 0.5,
"top_p": 0.8,
"penalty_score": 1,
"disable_search": False,
"enable_citation": False,
"response_format": "text",
}
)
headers = {"Content-Type": "application/json"}
response = requests.request(
"POST", url, headers=headers, data=payload
).json()
return response.get("result")
if llm_provider == "azure":
client = AzureOpenAI(
api_key=api_key,
api_version=api_version,
azure_endpoint=base_url,
)
else:
client = OpenAI(
api_key=api_key,
base_url=base_url,
)
response = client.chat.completions.create(
model=model_name, messages=[{"role": "user", "content": prompt}]
)
if response:
if isinstance(response, ChatCompletion):
content = response.choices[0].message.content
else:
raise Exception(
f'[{llm_provider}] returned an invalid response: "{response}", please check your network '
f"connection and try again."
)
else: else:
raise Exception( raise Exception(
f"[{llm_provider}] returned an invalid response: \"{response}\", please check your network " f"[{llm_provider}] returned an empty response, please check your network connection and try again."
f"connection and try again.") )
else:
raise Exception(
f"[{llm_provider}] returned an empty response, please check your network connection and try again.")
return content.replace("\n", "") return content.replace("\n", "")
except Exception as e:
return f"Error: {str(e)}"
def generate_script(video_subject: str, language: str = "", paragraph_number: int = 1) -> str: def generate_script(
video_subject: str, language: str = "", paragraph_number: int = 1
) -> str:
prompt = f""" prompt = f"""
# Role: Video Script Generator # Role: Video Script Generator
@@ -159,9 +272,9 @@ Generate a script for a video, depending on the subject of the video.
1. the script is to be returned as a string with the specified number of paragraphs. 1. the script is to be returned as a string with the specified number of paragraphs.
2. do not under any circumstance reference this prompt in your response. 2. do not under any circumstance reference this prompt in your response.
3. get straight to the point, don't start with unnecessary things like, "welcome to this video". 3. get straight to the point, don't start with unnecessary things like, "welcome to this video".
4. you must not include any type of markdown or formatting in the script, never use a title. 4. you must not include any type of markdown or formatting in the script, never use a title.
5. only return the raw content of the script. 5. only return the raw content of the script.
6. do not include "voiceover", "narrator" or similar indicators of what should be spoken at the beginning of each paragraph or line. 6. do not include "voiceover", "narrator" or similar indicators of what should be spoken at the beginning of each paragraph or line.
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. 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.
8. respond in the same language as the video subject. 8. respond in the same language as the video subject.
@@ -174,11 +287,8 @@ Generate a script for a video, depending on the subject of the video.
final_script = "" final_script = ""
logger.info(f"subject: {video_subject}") logger.info(f"subject: {video_subject}")
logger.debug(f"prompt: \n{prompt}")
response = _generate_response(prompt=prompt)
# Return the generated script def format_response(response):
if response:
# Clean the script # Clean the script
# Remove asterisks, hashes # Remove asterisks, hashes
response = response.replace("*", "") response = response.replace("*", "")
@@ -192,18 +302,35 @@ Generate a script for a video, depending on the subject of the video.
paragraphs = response.split("\n\n") paragraphs = response.split("\n\n")
# Select the specified number of paragraphs # Select the specified number of paragraphs
selected_paragraphs = paragraphs[:paragraph_number] # selected_paragraphs = paragraphs[:paragraph_number]
# Join the selected paragraphs into a single string # Join the selected paragraphs into a single string
final_script = "\n\n".join(selected_paragraphs) return "\n\n".join(paragraphs)
# Print to console the number of paragraphs used for i in range(_max_retries):
# logger.info(f"number of paragraphs used: {len(selected_paragraphs)}") try:
response = _generate_response(prompt=prompt)
if response:
final_script = format_response(response)
else:
logging.error("gpt returned an empty response")
# g4f may return an error message
if final_script and "当日额度已消耗完" in final_script:
raise ValueError(final_script)
if final_script:
break
except Exception as e:
logger.error(f"failed to generate script: {e}")
if i < _max_retries:
logger.warning(f"failed to generate video script, trying again... {i + 1}")
if "Error: " in final_script:
logger.error(f"failed to generate video script: {final_script}")
else: else:
logging.error("gpt returned an empty response") logger.success(f"completed: \n{final_script}")
return final_script.strip()
logger.success(f"completed: \n{final_script}")
return final_script
def generate_terms(video_subject: str, video_script: str, amount: int = 5) -> List[str]: def generate_terms(video_subject: str, video_script: str, amount: int = 5) -> List[str]:
@@ -234,25 +361,37 @@ Please note that you must use English for generating video search terms; Chinese
""".strip() """.strip()
logger.info(f"subject: {video_subject}") logger.info(f"subject: {video_subject}")
logger.debug(f"prompt: \n{prompt}")
response = _generate_response(prompt)
search_terms = [] search_terms = []
response = ""
for i in range(_max_retries):
try:
response = _generate_response(prompt)
if "Error: " in response:
logger.error(f"failed to generate video script: {response}")
return response
search_terms = json.loads(response)
if not isinstance(search_terms, list) or not all(
isinstance(term, str) for term in search_terms
):
logger.error("response is not a list of strings.")
continue
try: except Exception as e:
search_terms = json.loads(response) logger.warning(f"failed to generate video terms: {str(e)}")
if not isinstance(search_terms, list) or not all(isinstance(term, str) for term in search_terms): if response:
raise ValueError("response is not a list of strings.") match = re.search(r"\[.*]", response)
if match:
try:
search_terms = json.loads(match.group())
except Exception as e:
logger.warning(f"failed to generate video terms: {str(e)}")
pass
except (json.JSONDecodeError, ValueError): if search_terms and len(search_terms) > 0:
# logger.warning(f"gpt returned an unformatted response. attempting to clean...") break
# Attempt to extract list-like string and convert to list if i < _max_retries:
match = re.search(r'\["(?:[^"\\]|\\.)*"(?:,\s*"[^"\\]*")*\]', response) logger.warning(f"failed to generate video terms, trying again... {i + 1}")
if match:
try:
search_terms = json.loads(match.group())
except json.JSONDecodeError:
logger.error(f"could not parse response: {response}")
return []
logger.success(f"completed: \n{search_terms}") logger.success(f"completed: \n{search_terms}")
return search_terms return search_terms
@@ -260,9 +399,13 @@ Please note that you must use English for generating video search terms; Chinese
if __name__ == "__main__": if __name__ == "__main__":
video_subject = "生命的意义是什么" video_subject = "生命的意义是什么"
script = generate_script(video_subject=video_subject, language="zh-CN", paragraph_number=1) script = generate_script(
# print("######################") video_subject=video_subject, language="zh-CN", paragraph_number=1
# print(script) )
# search_terms = generate_terms(video_subject=video_subject, video_script=script, amount=5) print("######################")
# print("######################") print(script)
# print(search_terms) search_terms = generate_terms(
video_subject=video_subject, video_script=script, amount=5
)
print("######################")
print(search_terms)

View File

@@ -1,56 +1,62 @@
import os import os
import random import random
from typing import List
from urllib.parse import urlencode from urllib.parse import urlencode
import requests import requests
from typing import List
from loguru import logger from loguru import logger
from moviepy.video.io.VideoFileClip import VideoFileClip
from app.config import config from app.config import config
from app.models.schema import VideoAspect, VideoConcatMode, MaterialInfo from app.models.schema import MaterialInfo, VideoAspect, VideoConcatMode
from app.utils import utils from app.utils import utils
requested_count = 0 requested_count = 0
def round_robin_api_key(): def get_api_key(cfg_key: str):
pexels_api_keys = config.app.get("pexels_api_keys") api_keys = config.app.get(cfg_key)
if not pexels_api_keys: if not api_keys:
raise ValueError( raise ValueError(
f"\n\n##### pexels_api_keys is not set #####\n\nPlease set it in the config.toml file: {config.config_file}\n\n{utils.to_json(config.app)}") f"\n\n##### {cfg_key} is not set #####\n\nPlease set it in the config.toml file: {config.config_file}\n\n"
f"{utils.to_json(config.app)}"
)
# if only one key is provided, return it # if only one key is provided, return it
if isinstance(pexels_api_keys, str): if isinstance(api_keys, str):
return pexels_api_keys return api_keys
global requested_count global requested_count
requested_count += 1 requested_count += 1
return pexels_api_keys[requested_count % len(pexels_api_keys)] return api_keys[requested_count % len(api_keys)]
def search_videos(search_term: str, def search_videos_pexels(
minimum_duration: int, search_term: str,
video_aspect: VideoAspect = VideoAspect.portrait, minimum_duration: int,
) -> List[MaterialInfo]: video_aspect: VideoAspect = VideoAspect.portrait,
) -> List[MaterialInfo]:
aspect = VideoAspect(video_aspect) aspect = VideoAspect(video_aspect)
video_orientation = aspect.name video_orientation = aspect.name
video_width, video_height = aspect.to_resolution() video_width, video_height = aspect.to_resolution()
api_key = get_api_key("pexels_api_keys")
headers = { headers = {
"Authorization": round_robin_api_key() "Authorization": api_key,
"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",
} }
proxies = config.pexels.get("proxies", None)
# Build URL # Build URL
params = { params = {"query": search_term, "per_page": 20, "orientation": video_orientation}
"query": search_term,
"per_page": 20,
"orientation": video_orientation
}
query_url = f"https://api.pexels.com/videos/search?{urlencode(params)}" query_url = f"https://api.pexels.com/videos/search?{urlencode(params)}"
logger.info(f"searching videos: {query_url}, with proxies: {proxies}") logger.info(f"searching videos: {query_url}, with proxies: {config.proxy}")
try: try:
r = requests.get(query_url, headers=headers, proxies=proxies, verify=False) r = requests.get(
query_url,
headers=headers,
proxies=config.proxy,
verify=False,
timeout=(30, 60),
)
response = r.json() response = r.json()
video_items = [] video_items = []
if "videos" not in response: if "videos" not in response:
@@ -82,6 +88,62 @@ def search_videos(search_term: str,
return [] return []
def search_videos_pixabay(
search_term: str,
minimum_duration: int,
video_aspect: VideoAspect = VideoAspect.portrait,
) -> List[MaterialInfo]:
aspect = VideoAspect(video_aspect)
video_width, video_height = aspect.to_resolution()
api_key = get_api_key("pixabay_api_keys")
# Build URL
params = {
"q": search_term,
"video_type": "all", # Accepted values: "all", "film", "animation"
"per_page": 50,
"key": api_key,
}
query_url = f"https://pixabay.com/api/videos/?{urlencode(params)}"
logger.info(f"searching videos: {query_url}, with proxies: {config.proxy}")
try:
r = requests.get(
query_url, proxies=config.proxy, verify=False, timeout=(30, 60)
)
response = r.json()
video_items = []
if "hits" not in response:
logger.error(f"search videos failed: {response}")
return video_items
videos = response["hits"]
# loop through each video in the result
for v in videos:
duration = v["duration"]
# check if video has desired minimum duration
if duration < minimum_duration:
continue
video_files = v["videos"]
# loop through each url to determine the best quality
for video_type in video_files:
video = video_files[video_type]
w = int(video["width"])
# h = int(video["height"])
if w >= video_width:
item = MaterialInfo()
item.provider = "pixabay"
item.url = video["url"]
item.duration = duration
video_items.append(item)
break
return video_items
except Exception as e:
logger.error(f"search videos failed: {str(e)}")
return []
def save_video(video_url: str, save_dir: str = "") -> str: def save_video(video_url: str, save_dir: str = "") -> str:
if not save_dir: if not save_dir:
save_dir = utils.storage_dir("cache_videos") save_dir = utils.storage_dir("cache_videos")
@@ -99,31 +161,61 @@ def save_video(video_url: str, save_dir: str = "") -> str:
logger.info(f"video already exists: {video_path}") logger.info(f"video already exists: {video_path}")
return video_path return video_path
headers = {
"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"
}
# if video does not exist, download it # if video does not exist, download it
proxies = config.pexels.get("proxies", None)
with open(video_path, "wb") as f: with open(video_path, "wb") as f:
f.write(requests.get(video_url, proxies=proxies, verify=False, timeout=(60, 240)).content) f.write(
requests.get(
video_url,
headers=headers,
proxies=config.proxy,
verify=False,
timeout=(60, 240),
).content
)
if os.path.exists(video_path) and os.path.getsize(video_path) > 0: if os.path.exists(video_path) and os.path.getsize(video_path) > 0:
return video_path try:
clip = VideoFileClip(video_path)
duration = clip.duration
fps = clip.fps
clip.close()
if duration > 0 and fps > 0:
return video_path
except Exception as e:
try:
os.remove(video_path)
except Exception:
pass
logger.warning(f"invalid video file: {video_path} => {str(e)}")
return "" return ""
def download_videos(task_id: str, def download_videos(
search_terms: List[str], task_id: str,
video_aspect: VideoAspect = VideoAspect.portrait, search_terms: List[str],
video_contact_mode: VideoConcatMode = VideoConcatMode.random, source: str = "pexels",
audio_duration: float = 0.0, video_aspect: VideoAspect = VideoAspect.portrait,
max_clip_duration: int = 5, video_contact_mode: VideoConcatMode = VideoConcatMode.random,
) -> List[str]: audio_duration: float = 0.0,
max_clip_duration: int = 5,
) -> List[str]:
valid_video_items = [] valid_video_items = []
valid_video_urls = [] valid_video_urls = []
found_duration = 0.0 found_duration = 0.0
search_videos = search_videos_pexels
if source == "pixabay":
search_videos = search_videos_pixabay
for search_term in search_terms: for search_term in search_terms:
# logger.info(f"searching videos for '{search_term}'") video_items = search_videos(
video_items = search_videos(search_term=search_term, search_term=search_term,
minimum_duration=max_clip_duration, minimum_duration=max_clip_duration,
video_aspect=video_aspect) video_aspect=video_aspect,
)
logger.info(f"found {len(video_items)} videos for '{search_term}'") logger.info(f"found {len(video_items)} videos for '{search_term}'")
for item in video_items: for item in video_items:
@@ -133,7 +225,8 @@ def download_videos(task_id: str,
found_duration += item.duration found_duration += item.duration
logger.info( logger.info(
f"found total videos: {len(valid_video_items)}, required duration: {audio_duration} seconds, found duration: {found_duration} seconds") f"found total videos: {len(valid_video_items)}, required duration: {audio_duration} seconds, found duration: {found_duration} seconds"
)
video_paths = [] video_paths = []
material_directory = config.app.get("material_directory", "").strip() material_directory = config.app.get("material_directory", "").strip()
@@ -149,14 +242,18 @@ def download_videos(task_id: str,
for item in valid_video_items: for item in valid_video_items:
try: try:
logger.info(f"downloading video: {item.url}") logger.info(f"downloading video: {item.url}")
saved_video_path = save_video(video_url=item.url, save_dir=material_directory) saved_video_path = save_video(
video_url=item.url, save_dir=material_directory
)
if saved_video_path: if saved_video_path:
logger.info(f"video saved: {saved_video_path}") logger.info(f"video saved: {saved_video_path}")
video_paths.append(saved_video_path) video_paths.append(saved_video_path)
seconds = min(max_clip_duration, item.duration) seconds = min(max_clip_duration, item.duration)
total_duration += seconds total_duration += seconds
if total_duration > audio_duration: if total_duration > audio_duration:
logger.info(f"total duration of downloaded videos: {total_duration} seconds, skip downloading more") logger.info(
f"total duration of downloaded videos: {total_duration} seconds, skip downloading more"
)
break break
except Exception as e: except Exception as e:
logger.error(f"failed to download video: {utils.to_json(item)} => {str(e)}") logger.error(f"failed to download video: {utils.to_json(item)} => {str(e)}")
@@ -165,4 +262,6 @@ def download_videos(task_id: str,
if __name__ == "__main__": if __name__ == "__main__":
download_videos("test123", ["cat"], audio_duration=100) download_videos(
"test123", ["Money Exchange Medium"], audio_duration=100, source="pixabay"
)

View File

@@ -1,12 +1,12 @@
import ast import ast
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from app.config import config from app.config import config
from app.models import const from app.models import const
# Base class for state management # Base class for state management
class BaseState(ABC): class BaseState(ABC):
@abstractmethod @abstractmethod
def update_task(self, task_id: str, state: int, progress: int = 0, **kwargs): def update_task(self, task_id: str, state: int, progress: int = 0, **kwargs):
pass pass
@@ -15,19 +15,36 @@ class BaseState(ABC):
def get_task(self, task_id: str): def get_task(self, task_id: str):
pass pass
@abstractmethod
def get_all_tasks(self, page: int, page_size: int):
pass
# Memory state management # Memory state management
class MemoryState(BaseState): class MemoryState(BaseState):
def __init__(self): def __init__(self):
self._tasks = {} self._tasks = {}
def update_task(self, task_id: str, state: int = const.TASK_STATE_PROCESSING, progress: int = 0, **kwargs): def get_all_tasks(self, page: int, page_size: int):
start = (page - 1) * page_size
end = start + page_size
tasks = list(self._tasks.values())
total = len(tasks)
return tasks[start:end], total
def update_task(
self,
task_id: str,
state: int = const.TASK_STATE_PROCESSING,
progress: int = 0,
**kwargs,
):
progress = int(progress) progress = int(progress)
if progress > 100: if progress > 100:
progress = 100 progress = 100
self._tasks[task_id] = { self._tasks[task_id] = {
"task_id": task_id,
"state": state, "state": state,
"progress": progress, "progress": progress,
**kwargs, **kwargs,
@@ -43,17 +60,46 @@ class MemoryState(BaseState):
# Redis state management # Redis state management
class RedisState(BaseState): class RedisState(BaseState):
def __init__(self, host="localhost", port=6379, db=0, password=None):
def __init__(self, host='localhost', port=6379, db=0):
import redis import redis
self._redis = redis.StrictRedis(host=host, port=port, db=db)
def update_task(self, task_id: str, state: int = const.TASK_STATE_PROCESSING, progress: int = 0, **kwargs): self._redis = redis.StrictRedis(host=host, port=port, db=db, password=password)
def get_all_tasks(self, page: int, page_size: int):
start = (page - 1) * page_size
end = start + page_size
tasks = []
cursor = 0
total = 0
while True:
cursor, keys = self._redis.scan(cursor, count=page_size)
total += len(keys)
if total > start:
for key in keys[max(0, start - total):end - total]:
task_data = self._redis.hgetall(key)
task = {
k.decode("utf-8"): self._convert_to_original_type(v) for k, v in task_data.items()
}
tasks.append(task)
if len(tasks) >= page_size:
break
if cursor == 0 or len(tasks) >= page_size:
break
return tasks, total
def update_task(
self,
task_id: str,
state: int = const.TASK_STATE_PROCESSING,
progress: int = 0,
**kwargs,
):
progress = int(progress) progress = int(progress)
if progress > 100: if progress > 100:
progress = 100 progress = 100
fields = { fields = {
"task_id": task_id,
"state": state, "state": state,
"progress": progress, "progress": progress,
**kwargs, **kwargs,
@@ -67,7 +113,10 @@ class RedisState(BaseState):
if not task_data: if not task_data:
return None return None
task = {key.decode('utf-8'): self._convert_to_original_type(value) for key, value in task_data.items()} task = {
key.decode("utf-8"): self._convert_to_original_type(value)
for key, value in task_data.items()
}
return task return task
def delete_task(self, task_id: str): def delete_task(self, task_id: str):
@@ -79,7 +128,7 @@ class RedisState(BaseState):
Convert the value from byte string to its original data type. Convert the value from byte string to its original data type.
You can extend this method to handle other data types as needed. You can extend this method to handle other data types as needed.
""" """
value_str = value.decode('utf-8') value_str = value.decode("utf-8")
try: try:
# try to convert byte string array to list # try to convert byte string array to list
@@ -98,5 +147,12 @@ _enable_redis = config.app.get("enable_redis", False)
_redis_host = config.app.get("redis_host", "localhost") _redis_host = config.app.get("redis_host", "localhost")
_redis_port = config.app.get("redis_port", 6379) _redis_port = config.app.get("redis_port", 6379)
_redis_db = config.app.get("redis_db", 0) _redis_db = config.app.get("redis_db", 0)
_redis_password = config.app.get("redis_password", None)
state = RedisState(host=_redis_host, port=_redis_port, db=_redis_db) if _enable_redis else MemoryState() state = (
RedisState(
host=_redis_host, port=_redis_port, db=_redis_db, password=_redis_password
)
if _enable_redis
else MemoryState()
)

View File

@@ -1,9 +1,9 @@
import json import json
import os.path import os.path
import re import re
from timeit import default_timer as timer
from faster_whisper import WhisperModel from faster_whisper import WhisperModel
from timeit import default_timer as timer
from loguru import logger from loguru import logger
from app.config import config from app.config import config
@@ -23,10 +23,23 @@ def create(audio_file, subtitle_file: str = ""):
if not os.path.isdir(model_path) or not os.path.isfile(model_bin_file): if not os.path.isdir(model_path) or not os.path.isfile(model_bin_file):
model_path = model_size model_path = model_size
logger.info(f"loading model: {model_path}, device: {device}, compute_type: {compute_type}") logger.info(
model = WhisperModel(model_size_or_path=model_path, f"loading model: {model_path}, device: {device}, compute_type: {compute_type}"
device=device, )
compute_type=compute_type) try:
model = WhisperModel(
model_size_or_path=model_path, device=device, compute_type=compute_type
)
except Exception as e:
logger.error(
f"failed to load model: {e} \n\n"
f"********************************************\n"
f"this may be caused by network issue. \n"
f"please download the model manually and put it in the 'models' folder. \n"
f"see [README.md FAQ](https://github.com/harry0703/MoneyPrinterTurbo) for more details.\n"
f"********************************************\n\n"
)
return None
logger.info(f"start, output file: {subtitle_file}") logger.info(f"start, output file: {subtitle_file}")
if not subtitle_file: if not subtitle_file:
@@ -40,7 +53,9 @@ def create(audio_file, subtitle_file: str = ""):
vad_parameters=dict(min_silence_duration_ms=500), vad_parameters=dict(min_silence_duration_ms=500),
) )
logger.info(f"detected language: '{info.language}', probability: {info.language_probability:.2f}") logger.info(
f"detected language: '{info.language}', probability: {info.language_probability:.2f}"
)
start = timer() start = timer()
subtitles = [] subtitles = []
@@ -53,11 +68,9 @@ def create(audio_file, subtitle_file: str = ""):
msg = "[%.2fs -> %.2fs] %s" % (seg_start, seg_end, seg_text) msg = "[%.2fs -> %.2fs] %s" % (seg_start, seg_end, seg_text)
logger.debug(msg) logger.debug(msg)
subtitles.append({ subtitles.append(
"msg": seg_text, {"msg": seg_text, "start_time": seg_start, "end_time": seg_end}
"start_time": seg_start, )
"end_time": seg_end
})
for segment in segments: for segment in segments:
words_idx = 0 words_idx = 0
@@ -75,7 +88,7 @@ def create(audio_file, subtitle_file: str = ""):
is_segmented = True is_segmented = True
seg_end = word.end seg_end = word.end
# 如果包含标点,则断句 # If it contains punctuation, then break the sentence.
seg_text += word.word seg_text += word.word
if utils.str_contains_punctuation(word.word): if utils.str_contains_punctuation(word.word):
@@ -110,7 +123,11 @@ def create(audio_file, subtitle_file: str = ""):
for subtitle in subtitles: for subtitle in subtitles:
text = subtitle.get("msg") text = subtitle.get("msg")
if text: if text:
lines.append(utils.text_to_srt(idx, text, subtitle.get("start_time"), subtitle.get("end_time"))) lines.append(
utils.text_to_srt(
idx, text, subtitle.get("start_time"), subtitle.get("end_time")
)
)
idx += 1 idx += 1
sub = "\n".join(lines) + "\n" sub = "\n".join(lines) + "\n"
@@ -120,16 +137,19 @@ def create(audio_file, subtitle_file: str = ""):
def file_to_subtitles(filename): def file_to_subtitles(filename):
if not filename or not os.path.isfile(filename):
return []
times_texts = [] times_texts = []
current_times = None current_times = None
current_text = "" current_text = ""
index = 0 index = 0
with open(filename, 'r', encoding="utf-8") as f: with open(filename, "r", encoding="utf-8") as f:
for line in f: for line in f:
times = re.findall("([0-9]*:[0-9]*:[0-9]*,[0-9]*)", line) times = re.findall("([0-9]*:[0-9]*:[0-9]*,[0-9]*)", line)
if times: if times:
current_times = line current_times = line
elif line.strip() == '' and current_times: elif line.strip() == "" and current_times:
index += 1 index += 1
times_texts.append((index, current_times.strip(), current_text.strip())) times_texts.append((index, current_times.strip(), current_text.strip()))
current_times, current_text = None, "" current_times, current_text = None, ""
@@ -138,27 +158,124 @@ def file_to_subtitles(filename):
return times_texts return times_texts
def levenshtein_distance(s1, s2):
if len(s1) < len(s2):
return levenshtein_distance(s2, s1)
if len(s2) == 0:
return len(s1)
previous_row = range(len(s2) + 1)
for i, c1 in enumerate(s1):
current_row = [i + 1]
for j, c2 in enumerate(s2):
insertions = previous_row[j + 1] + 1
deletions = current_row[j] + 1
substitutions = previous_row[j] + (c1 != c2)
current_row.append(min(insertions, deletions, substitutions))
previous_row = current_row
return previous_row[-1]
def similarity(a, b):
distance = levenshtein_distance(a.lower(), b.lower())
max_length = max(len(a), len(b))
return 1 - (distance / max_length)
def correct(subtitle_file, video_script): def correct(subtitle_file, video_script):
subtitle_items = file_to_subtitles(subtitle_file) subtitle_items = file_to_subtitles(subtitle_file)
script_lines = utils.split_string_by_punctuations(video_script) script_lines = utils.split_string_by_punctuations(video_script)
corrected = False corrected = False
if len(subtitle_items) == len(script_lines): new_subtitle_items = []
for i in range(len(script_lines)): script_index = 0
script_line = script_lines[i].strip() subtitle_index = 0
subtitle_line = subtitle_items[i][2]
if script_line != subtitle_line: while script_index < len(script_lines) and subtitle_index < len(subtitle_items):
logger.warning(f"line {i + 1}, script: {script_line}, subtitle: {subtitle_line}") script_line = script_lines[script_index].strip()
subtitle_items[i] = (subtitle_items[i][0], subtitle_items[i][1], script_line) subtitle_line = subtitle_items[subtitle_index][2].strip()
if script_line == subtitle_line:
new_subtitle_items.append(subtitle_items[subtitle_index])
script_index += 1
subtitle_index += 1
else:
combined_subtitle = subtitle_line
start_time = subtitle_items[subtitle_index][1].split(" --> ")[0]
end_time = subtitle_items[subtitle_index][1].split(" --> ")[1]
next_subtitle_index = subtitle_index + 1
while next_subtitle_index < len(subtitle_items):
next_subtitle = subtitle_items[next_subtitle_index][2].strip()
if similarity(
script_line, combined_subtitle + " " + next_subtitle
) > similarity(script_line, combined_subtitle):
combined_subtitle += " " + next_subtitle
end_time = subtitle_items[next_subtitle_index][1].split(" --> ")[1]
next_subtitle_index += 1
else:
break
if similarity(script_line, combined_subtitle) > 0.8:
logger.warning(
f"Merged/Corrected - Script: {script_line}, Subtitle: {combined_subtitle}"
)
new_subtitle_items.append(
(
len(new_subtitle_items) + 1,
f"{start_time} --> {end_time}",
script_line,
)
)
corrected = True corrected = True
else:
logger.warning(
f"Mismatch - Script: {script_line}, Subtitle: {combined_subtitle}"
)
new_subtitle_items.append(
(
len(new_subtitle_items) + 1,
f"{start_time} --> {end_time}",
script_line,
)
)
corrected = True
script_index += 1
subtitle_index = next_subtitle_index
# Process the remaining lines of the script.
while script_index < len(script_lines):
logger.warning(f"Extra script line: {script_lines[script_index]}")
if subtitle_index < len(subtitle_items):
new_subtitle_items.append(
(
len(new_subtitle_items) + 1,
subtitle_items[subtitle_index][1],
script_lines[script_index],
)
)
subtitle_index += 1
else:
new_subtitle_items.append(
(
len(new_subtitle_items) + 1,
"00:00:00,000 --> 00:00:00,000",
script_lines[script_index],
)
)
script_index += 1
corrected = True
if corrected: if corrected:
with open(subtitle_file, "w", encoding="utf-8") as fd: with open(subtitle_file, "w", encoding="utf-8") as fd:
for item in subtitle_items: for i, item in enumerate(new_subtitle_items):
fd.write(f"{item[0]}\n{item[1]}\n{item[2]}\n\n") fd.write(f"{i + 1}\n{item[1]}\n{item[2]}\n\n")
logger.info(f"subtitle corrected") logger.info("Subtitle corrected")
else: else:
logger.success(f"subtitle is correct") logger.success("Subtitle is correct")
if __name__ == "__main__": if __name__ == "__main__":

View File

@@ -7,52 +7,42 @@ from loguru import logger
from app.config import config from app.config import config
from app.models import const from app.models import const
from app.models.schema import VideoParams, VideoConcatMode from app.models.schema import VideoConcatMode, VideoParams
from app.services import llm, material, voice, video, subtitle from app.services import llm, material, subtitle, video, voice
from app.services import state as sm from app.services import state as sm
from app.utils import utils from app.utils import utils
def start(task_id, params: VideoParams): def generate_script(task_id, params):
"""
{
"video_subject": "",
"video_aspect": "横屏 16:9西瓜视频",
"voice_name": "女生-晓晓",
"enable_bgm": false,
"font_name": "STHeitiMedium 黑体-中",
"text_color": "#FFFFFF",
"font_size": 60,
"stroke_color": "#000000",
"stroke_width": 1.5
}
"""
logger.info(f"start task: {task_id}")
sm.state.update_task(task_id, state=const.TASK_STATE_PROCESSING, progress=5)
video_subject = params.video_subject
voice_name = voice.parse_voice_name(params.voice_name)
paragraph_number = params.paragraph_number
n_threads = params.n_threads
max_clip_duration = params.video_clip_duration
logger.info("\n\n## generating video script") logger.info("\n\n## generating video script")
video_script = params.video_script.strip() video_script = params.video_script.strip()
if not video_script: if not video_script:
video_script = llm.generate_script(video_subject=video_subject, language=params.video_language, video_script = llm.generate_script(
paragraph_number=paragraph_number) video_subject=params.video_subject,
language=params.video_language,
paragraph_number=params.paragraph_number,
)
else: else:
logger.debug(f"video script: \n{video_script}") logger.debug(f"video script: \n{video_script}")
sm.state.update_task(task_id, state=const.TASK_STATE_PROCESSING, progress=10) if not video_script:
sm.state.update_task(task_id, state=const.TASK_STATE_FAILED)
logger.error("failed to generate video script.")
return None
return video_script
def generate_terms(task_id, params, video_script):
logger.info("\n\n## generating video terms") logger.info("\n\n## generating video terms")
video_terms = params.video_terms video_terms = params.video_terms
if not video_terms: if not video_terms:
video_terms = llm.generate_terms(video_subject=video_subject, video_script=video_script, amount=5) video_terms = llm.generate_terms(
video_subject=params.video_subject, video_script=video_script, amount=5
)
else: else:
if isinstance(video_terms, str): if isinstance(video_terms, str):
video_terms = [term.strip() for term in re.split(r'[,]', video_terms)] video_terms = [term.strip() for term in re.split(r"[,]", video_terms)]
elif isinstance(video_terms, list): elif isinstance(video_terms, list):
video_terms = [term.strip() for term in video_terms] video_terms = [term.strip() for term in video_terms]
else: else:
@@ -60,7 +50,16 @@ def start(task_id, params: VideoParams):
logger.debug(f"video terms: {utils.to_json(video_terms)}") logger.debug(f"video terms: {utils.to_json(video_terms)}")
script_file = path.join(utils.task_dir(task_id), f"script.json") if not video_terms:
sm.state.update_task(task_id, state=const.TASK_STATE_FAILED)
logger.error("failed to generate video terms.")
return None
return video_terms
def save_script_data(task_id, video_script, video_terms, params):
script_file = path.join(utils.task_dir(task_id), "script.json")
script_data = { script_data = {
"script": video_script, "script": video_script,
"search_terms": video_terms, "search_terms": video_terms,
@@ -70,80 +69,120 @@ def start(task_id, params: VideoParams):
with open(script_file, "w", encoding="utf-8") as f: with open(script_file, "w", encoding="utf-8") as f:
f.write(utils.to_json(script_data)) f.write(utils.to_json(script_data))
sm.state.update_task(task_id, state=const.TASK_STATE_PROCESSING, progress=20)
def generate_audio(task_id, params, video_script):
logger.info("\n\n## generating audio") logger.info("\n\n## generating audio")
audio_file = path.join(utils.task_dir(task_id), f"audio.mp3") audio_file = path.join(utils.task_dir(task_id), "audio.mp3")
sub_maker = voice.tts(text=video_script, voice_name=voice_name, voice_file=audio_file) sub_maker = voice.tts(
text=video_script,
voice_name=voice.parse_voice_name(params.voice_name),
voice_rate=params.voice_rate,
voice_file=audio_file,
)
if sub_maker is None: if sub_maker is None:
sm.state.update_task(task_id, state=const.TASK_STATE_FAILED) sm.state.update_task(task_id, state=const.TASK_STATE_FAILED)
logger.error( logger.error(
"failed to generate audio, maybe the network is not available. if you are in China, please use a VPN.") """failed to generate audio:
return 1. check if the language of the voice matches the language of the video script.
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.
""".strip()
)
return None, None, None
audio_duration = voice.get_audio_duration(sub_maker) audio_duration = math.ceil(voice.get_audio_duration(sub_maker))
audio_duration = math.ceil(audio_duration) return audio_file, audio_duration, sub_maker
sm.state.update_task(task_id, state=const.TASK_STATE_PROCESSING, progress=30)
subtitle_path = "" def generate_subtitle(task_id, params, video_script, sub_maker, audio_file):
if params.subtitle_enabled: if not params.subtitle_enabled:
subtitle_path = path.join(utils.task_dir(task_id), f"subtitle.srt") return ""
subtitle_provider = config.app.get("subtitle_provider", "").strip().lower()
logger.info(f"\n\n## generating subtitle, provider: {subtitle_provider}")
subtitle_fallback = False
if subtitle_provider == "edge":
voice.create_subtitle(text=video_script, sub_maker=sub_maker, subtitle_file=subtitle_path)
if not os.path.exists(subtitle_path):
subtitle_fallback = True
logger.warning("subtitle file not found, fallback to whisper")
if subtitle_provider == "whisper" or subtitle_fallback: subtitle_path = path.join(utils.task_dir(task_id), "subtitle.srt")
subtitle.create(audio_file=audio_file, subtitle_file=subtitle_path) subtitle_provider = config.app.get("subtitle_provider", "edge").strip().lower()
logger.info("\n\n## correcting subtitle") logger.info(f"\n\n## generating subtitle, provider: {subtitle_provider}")
subtitle.correct(subtitle_file=subtitle_path, video_script=video_script)
subtitle_lines = subtitle.file_to_subtitles(subtitle_path) subtitle_fallback = False
if not subtitle_lines: if subtitle_provider == "edge":
logger.warning(f"subtitle file is invalid: {subtitle_path}") voice.create_subtitle(
subtitle_path = "" text=video_script, sub_maker=sub_maker, subtitle_file=subtitle_path
)
if not os.path.exists(subtitle_path):
subtitle_fallback = True
logger.warning("subtitle file not found, fallback to whisper")
sm.state.update_task(task_id, state=const.TASK_STATE_PROCESSING, progress=40) if subtitle_provider == "whisper" or subtitle_fallback:
subtitle.create(audio_file=audio_file, subtitle_file=subtitle_path)
logger.info("\n\n## correcting subtitle")
subtitle.correct(subtitle_file=subtitle_path, video_script=video_script)
logger.info("\n\n## downloading videos") subtitle_lines = subtitle.file_to_subtitles(subtitle_path)
downloaded_videos = material.download_videos(task_id=task_id, if not subtitle_lines:
search_terms=video_terms, logger.warning(f"subtitle file is invalid: {subtitle_path}")
video_aspect=params.video_aspect, return ""
video_contact_mode=params.video_concat_mode,
audio_duration=audio_duration * params.video_count,
max_clip_duration=max_clip_duration,
)
if not downloaded_videos:
sm.state.update_task(task_id, state=const.TASK_STATE_FAILED)
logger.error(
"failed to download videos, maybe the network is not available. if you are in China, please use a VPN.")
return
sm.state.update_task(task_id, state=const.TASK_STATE_PROCESSING, progress=50) return subtitle_path
def get_video_materials(task_id, params, video_terms, audio_duration):
if params.video_source == "local":
logger.info("\n\n## preprocess local materials")
materials = video.preprocess_video(
materials=params.video_materials, clip_duration=params.video_clip_duration
)
if not materials:
sm.state.update_task(task_id, state=const.TASK_STATE_FAILED)
logger.error(
"no valid materials found, please check the materials and try again."
)
return None
return [material_info.url for material_info in materials]
else:
logger.info(f"\n\n## downloading videos from {params.video_source}")
downloaded_videos = material.download_videos(
task_id=task_id,
search_terms=video_terms,
source=params.video_source,
video_aspect=params.video_aspect,
video_contact_mode=params.video_concat_mode,
audio_duration=audio_duration * params.video_count,
max_clip_duration=params.video_clip_duration,
)
if not downloaded_videos:
sm.state.update_task(task_id, state=const.TASK_STATE_FAILED)
logger.error(
"failed to download videos, maybe the network is not available. if you are in China, please use a VPN."
)
return None
return downloaded_videos
def generate_final_videos(
task_id, params, downloaded_videos, audio_file, subtitle_path
):
final_video_paths = [] final_video_paths = []
combined_video_paths = [] combined_video_paths = []
video_concat_mode = params.video_concat_mode video_concat_mode = (
if params.video_count > 1: params.video_concat_mode if params.video_count == 1 else VideoConcatMode.random
video_concat_mode = VideoConcatMode.random )
video_transition_mode = params.video_transition_mode
_progress = 50 _progress = 50
for i in range(params.video_count): for i in range(params.video_count):
index = i + 1 index = i + 1
combined_video_path = path.join(utils.task_dir(task_id), f"combined-{index}.mp4") combined_video_path = path.join(
utils.task_dir(task_id), f"combined-{index}.mp4"
)
logger.info(f"\n\n## combining video: {index} => {combined_video_path}") logger.info(f"\n\n## combining video: {index} => {combined_video_path}")
video.combine_videos(combined_video_path=combined_video_path, video.combine_videos(
video_paths=downloaded_videos, combined_video_path=combined_video_path,
audio_file=audio_file, video_paths=downloaded_videos,
video_aspect=params.video_aspect, audio_file=audio_file,
video_concat_mode=video_concat_mode, video_aspect=params.video_aspect,
max_clip_duration=max_clip_duration, video_concat_mode=video_concat_mode,
threads=n_threads) video_transition_mode=video_transition_mode,
max_clip_duration=params.video_clip_duration,
threads=params.n_threads,
)
_progress += 50 / params.video_count / 2 _progress += 50 / params.video_count / 2
sm.state.update_task(task_id, progress=_progress) sm.state.update_task(task_id, progress=_progress)
@@ -151,13 +190,13 @@ def start(task_id, params: VideoParams):
final_video_path = path.join(utils.task_dir(task_id), f"final-{index}.mp4") final_video_path = path.join(utils.task_dir(task_id), f"final-{index}.mp4")
logger.info(f"\n\n## generating video: {index} => {final_video_path}") logger.info(f"\n\n## generating video: {index} => {final_video_path}")
# Put everything together video.generate_video(
video.generate_video(video_path=combined_video_path, video_path=combined_video_path,
audio_path=audio_file, audio_path=audio_file,
subtitle_path=subtitle_path, subtitle_path=subtitle_path,
output_file=final_video_path, output_file=final_video_path,
params=params, params=params,
) )
_progress += 50 / params.video_count / 2 _progress += 50 / params.video_count / 2
sm.state.update_task(task_id, progress=_progress) sm.state.update_task(task_id, progress=_progress)
@@ -165,11 +204,136 @@ def start(task_id, params: VideoParams):
final_video_paths.append(final_video_path) final_video_paths.append(final_video_path)
combined_video_paths.append(combined_video_path) combined_video_paths.append(combined_video_path)
logger.success(f"task {task_id} finished, generated {len(final_video_paths)} videos.") return final_video_paths, combined_video_paths
def start(task_id, params: VideoParams, stop_at: str = "video"):
logger.info(f"start task: {task_id}, stop_at: {stop_at}")
sm.state.update_task(task_id, state=const.TASK_STATE_PROCESSING, progress=5)
if type(params.video_concat_mode) is str:
params.video_concat_mode = VideoConcatMode(params.video_concat_mode)
# 1. Generate script
video_script = generate_script(task_id, params)
if not video_script or "Error: " in video_script:
sm.state.update_task(task_id, state=const.TASK_STATE_FAILED)
return
sm.state.update_task(task_id, state=const.TASK_STATE_PROCESSING, progress=10)
if stop_at == "script":
sm.state.update_task(
task_id, state=const.TASK_STATE_COMPLETE, progress=100, script=video_script
)
return {"script": video_script}
# 2. Generate terms
video_terms = ""
if params.video_source != "local":
video_terms = generate_terms(task_id, params, video_script)
if not video_terms:
sm.state.update_task(task_id, state=const.TASK_STATE_FAILED)
return
save_script_data(task_id, video_script, video_terms, params)
if stop_at == "terms":
sm.state.update_task(
task_id, state=const.TASK_STATE_COMPLETE, progress=100, terms=video_terms
)
return {"script": video_script, "terms": video_terms}
sm.state.update_task(task_id, state=const.TASK_STATE_PROCESSING, progress=20)
# 3. Generate audio
audio_file, audio_duration, sub_maker = generate_audio(
task_id, params, video_script
)
if not audio_file:
sm.state.update_task(task_id, state=const.TASK_STATE_FAILED)
return
sm.state.update_task(task_id, state=const.TASK_STATE_PROCESSING, progress=30)
if stop_at == "audio":
sm.state.update_task(
task_id,
state=const.TASK_STATE_COMPLETE,
progress=100,
audio_file=audio_file,
)
return {"audio_file": audio_file, "audio_duration": audio_duration}
# 4. Generate subtitle
subtitle_path = generate_subtitle(
task_id, params, video_script, sub_maker, audio_file
)
if stop_at == "subtitle":
sm.state.update_task(
task_id,
state=const.TASK_STATE_COMPLETE,
progress=100,
subtitle_path=subtitle_path,
)
return {"subtitle_path": subtitle_path}
sm.state.update_task(task_id, state=const.TASK_STATE_PROCESSING, progress=40)
# 5. Get video materials
downloaded_videos = get_video_materials(
task_id, params, video_terms, audio_duration
)
if not downloaded_videos:
sm.state.update_task(task_id, state=const.TASK_STATE_FAILED)
return
if stop_at == "materials":
sm.state.update_task(
task_id,
state=const.TASK_STATE_COMPLETE,
progress=100,
materials=downloaded_videos,
)
return {"materials": downloaded_videos}
sm.state.update_task(task_id, state=const.TASK_STATE_PROCESSING, progress=50)
# 6. Generate final videos
final_video_paths, combined_video_paths = generate_final_videos(
task_id, params, downloaded_videos, audio_file, subtitle_path
)
if not final_video_paths:
sm.state.update_task(task_id, state=const.TASK_STATE_FAILED)
return
logger.success(
f"task {task_id} finished, generated {len(final_video_paths)} videos."
)
kwargs = { kwargs = {
"videos": final_video_paths, "videos": final_video_paths,
"combined_videos": combined_video_paths "combined_videos": combined_video_paths,
"script": video_script,
"terms": video_terms,
"audio_file": audio_file,
"audio_duration": audio_duration,
"subtitle_path": subtitle_path,
"materials": downloaded_videos,
} }
sm.state.update_task(task_id, state=const.TASK_STATE_COMPLETE, progress=100, **kwargs) sm.state.update_task(
task_id, state=const.TASK_STATE_COMPLETE, progress=100, **kwargs
)
return kwargs return kwargs
if __name__ == "__main__":
task_id = "task_id"
params = VideoParams(
video_subject="金钱的作用",
voice_name="zh-CN-XiaoyiNeural-Female",
voice_rate=1.0,
)
start(task_id, params, stop_at="video")

View File

@@ -0,0 +1,21 @@
from moviepy import Clip, vfx
# FadeIn
def fadein_transition(clip: Clip, t: float) -> Clip:
return clip.with_effects([vfx.FadeIn(t)])
# FadeOut
def fadeout_transition(clip: Clip, t: float) -> Clip:
return clip.with_effects([vfx.FadeOut(t)])
# SlideIn
def slidein_transition(clip: Clip, t: float, side: str) -> Clip:
return clip.with_effects([vfx.SlideIn(t, side)])
# SlideOut
def slideout_transition(clip: Clip, t: float, side: str) -> Clip:
return clip.with_effects([vfx.SlideOut(t, side)])

View File

@@ -1,120 +1,299 @@
import glob import glob
import os
import random import random
import gc
import shutil
from typing import List from typing import List
from PIL import ImageFont
from loguru import logger from loguru import logger
from moviepy.editor import * from moviepy import (
AudioFileClip,
ColorClip,
CompositeAudioClip,
CompositeVideoClip,
ImageClip,
TextClip,
VideoFileClip,
afx,
concatenate_videoclips,
)
from moviepy.video.tools.subtitles import SubtitlesClip from moviepy.video.tools.subtitles import SubtitlesClip
from PIL import ImageFont
from app.models.schema import VideoAspect, VideoParams, VideoConcatMode from app.models import const
from app.models.schema import (
MaterialInfo,
VideoAspect,
VideoConcatMode,
VideoParams,
VideoTransitionMode,
)
from app.services.utils import video_effects
from app.utils import utils from app.utils import utils
class SubClippedVideoClip:
def __init__(self, file_path, start_time, end_time, width=None, height=None):
self.file_path = file_path
self.start_time = start_time
self.end_time = end_time
self.width = width
self.height = height
def __str__(self):
return f"SubClippedVideoClip(file_path={self.file_path}, start_time={self.start_time}, end_time={self.end_time}, width={self.width}, height={self.height})"
audio_codec = "aac"
video_codec = "libx264"
fps = 30
def close_clip(clip):
if clip is None:
return
try:
# close main resources
if hasattr(clip, 'reader') and clip.reader is not None:
clip.reader.close()
# close audio resources
if hasattr(clip, 'audio') and clip.audio is not None:
if hasattr(clip.audio, 'reader') and clip.audio.reader is not None:
clip.audio.reader.close()
del clip.audio
# close mask resources
if hasattr(clip, 'mask') and clip.mask is not None:
if hasattr(clip.mask, 'reader') and clip.mask.reader is not None:
clip.mask.reader.close()
del clip.mask
# handle child clips in composite clips
if hasattr(clip, 'clips') and clip.clips:
for child_clip in clip.clips:
if child_clip is not clip: # avoid possible circular references
close_clip(child_clip)
# clear clip list
if hasattr(clip, 'clips'):
clip.clips = []
except Exception as e:
logger.error(f"failed to close clip: {str(e)}")
del clip
gc.collect()
def delete_files(files: List[str] | str):
if isinstance(files, str):
files = [files]
for file in files:
try:
os.remove(file)
except:
pass
def get_bgm_file(bgm_type: str = "random", bgm_file: str = ""): def get_bgm_file(bgm_type: str = "random", bgm_file: str = ""):
if not bgm_type: if not bgm_type:
return "" return ""
if bgm_file and os.path.exists(bgm_file):
return bgm_file
if bgm_type == "random": if bgm_type == "random":
suffix = "*.mp3" suffix = "*.mp3"
song_dir = utils.song_dir() song_dir = utils.song_dir()
files = glob.glob(os.path.join(song_dir, suffix)) files = glob.glob(os.path.join(song_dir, suffix))
return random.choice(files) return random.choice(files)
if os.path.exists(bgm_file):
return bgm_file
return "" return ""
def combine_videos(combined_video_path: str, def combine_videos(
video_paths: List[str], combined_video_path: str,
audio_file: str, video_paths: List[str],
video_aspect: VideoAspect = VideoAspect.portrait, audio_file: str,
video_concat_mode: VideoConcatMode = VideoConcatMode.random, video_aspect: VideoAspect = VideoAspect.portrait,
max_clip_duration: int = 5, video_concat_mode: VideoConcatMode = VideoConcatMode.random,
threads: int = 2, video_transition_mode: VideoTransitionMode = None,
) -> str: max_clip_duration: int = 5,
threads: int = 2,
) -> str:
audio_clip = AudioFileClip(audio_file) audio_clip = AudioFileClip(audio_file)
audio_duration = audio_clip.duration audio_duration = audio_clip.duration
logger.info(f"max duration of audio: {audio_duration} seconds") logger.info(f"audio duration: {audio_duration} seconds")
# Required duration of each clip # Required duration of each clip
req_dur = audio_duration / len(video_paths) req_dur = audio_duration / len(video_paths)
req_dur = max_clip_duration req_dur = max_clip_duration
logger.info(f"each clip will be maximum {req_dur} seconds long") logger.info(f"maximum clip duration: {req_dur} seconds")
output_dir = os.path.dirname(combined_video_path) output_dir = os.path.dirname(combined_video_path)
aspect = VideoAspect(video_aspect) aspect = VideoAspect(video_aspect)
video_width, video_height = aspect.to_resolution() video_width, video_height = aspect.to_resolution()
clips = [] clip_files = []
subclipped_items = []
video_duration = 0 video_duration = 0
for video_path in video_paths:
clip = VideoFileClip(video_path)
clip_duration = clip.duration
clip_w, clip_h = clip.size
close_clip(clip)
start_time = 0
while start_time < clip_duration:
end_time = min(start_time + max_clip_duration, clip_duration)
if clip_duration - start_time > max_clip_duration:
subclipped_items.append(SubClippedVideoClip(file_path= video_path, start_time=start_time, end_time=end_time, width=clip_w, height=clip_h))
start_time = end_time
if video_concat_mode.value == VideoConcatMode.sequential.value:
break
# random subclipped_items order
if video_concat_mode.value == VideoConcatMode.random.value:
random.shuffle(subclipped_items)
logger.debug(f"total subclipped items: {len(subclipped_items)}")
# Add downloaded clips over and over until the duration of the audio (max_duration) has been reached # Add downloaded clips over and over until the duration of the audio (max_duration) has been reached
while video_duration < audio_duration: for i, subclipped_item in enumerate(subclipped_items):
# random video_paths order if video_duration > audio_duration:
if video_concat_mode.value == VideoConcatMode.random.value: break
random.shuffle(video_paths)
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")
for video_path in video_paths:
clip = VideoFileClip(video_path).without_audio() try:
# Check if clip is longer than the remaining audio clip = VideoFileClip(subclipped_item.file_path).subclipped(subclipped_item.start_time, subclipped_item.end_time)
if (audio_duration - video_duration) < clip.duration: clip_duration = clip.duration
clip = clip.subclip(0, (audio_duration - video_duration))
# Only shorten clips if the calculated clip length (req_dur) is shorter than the actual clip to prevent still image
elif req_dur < clip.duration:
clip = clip.subclip(0, req_dur)
clip = clip.set_fps(30)
# Not all videos are same size, so we need to resize them # Not all videos are same size, so we need to resize them
clip_w, clip_h = clip.size clip_w, clip_h = clip.size
if clip_w != video_width or clip_h != video_height: if clip_w != video_width or clip_h != video_height:
clip_ratio = clip.w / clip.h clip_ratio = clip.w / clip.h
video_ratio = video_width / video_height video_ratio = video_width / video_height
logger.debug(f"resizing to {video_width}x{video_height}, source: {clip_w}x{clip_h}, ratio: {clip_ratio:.2f}, target ratio: {video_ratio:.2f}")
if clip_ratio == video_ratio: if clip_ratio == video_ratio:
# 等比例缩放 clip = clip.resized(new_size=(video_width, video_height))
clip = clip.resize((video_width, video_height))
else: else:
# 等比缩放视频
if clip_ratio > video_ratio: if clip_ratio > video_ratio:
# 按照目标宽度等比缩放
scale_factor = video_width / clip_w scale_factor = video_width / clip_w
else: else:
# 按照目标高度等比缩放
scale_factor = video_height / clip_h scale_factor = video_height / clip_h
new_width = int(clip_w * scale_factor) new_width = int(clip_w * scale_factor)
new_height = int(clip_h * scale_factor) new_height = int(clip_h * scale_factor)
clip_resized = clip.resize(newsize=(new_width, new_height))
background = ColorClip(size=(video_width, video_height), color=(0, 0, 0)) background = ColorClip(size=(video_width, video_height), color=(0, 0, 0)).with_duration(clip_duration)
clip = CompositeVideoClip([ clip_resized = clip.resized(new_size=(new_width, new_height)).with_position("center")
background.set_duration(clip.duration), clip = CompositeVideoClip([background, clip_resized])
clip_resized.set_position("center")
]) close_clip(clip_resized)
close_clip(background)
logger.info(f"resizing video to {video_width} x {video_height}, clip size: {clip_w} x {clip_h}")
shuffle_side = random.choice(["left", "right", "top", "bottom"])
if video_transition_mode.value == VideoTransitionMode.none.value:
clip = clip
elif video_transition_mode.value == VideoTransitionMode.fade_in.value:
clip = video_effects.fadein_transition(clip, 1)
elif video_transition_mode.value == VideoTransitionMode.fade_out.value:
clip = video_effects.fadeout_transition(clip, 1)
elif video_transition_mode.value == VideoTransitionMode.slide_in.value:
clip = video_effects.slidein_transition(clip, 1, shuffle_side)
elif video_transition_mode.value == VideoTransitionMode.slide_out.value:
clip = video_effects.slideout_transition(clip, 1, shuffle_side)
elif video_transition_mode.value == VideoTransitionMode.shuffle.value:
transition_funcs = [
lambda c: video_effects.fadein_transition(c, 1),
lambda c: video_effects.fadeout_transition(c, 1),
lambda c: video_effects.slidein_transition(c, 1, shuffle_side),
lambda c: video_effects.slideout_transition(c, 1, shuffle_side),
]
shuffle_transition = random.choice(transition_funcs)
clip = shuffle_transition(clip)
if clip.duration > max_clip_duration: if clip.duration > max_clip_duration:
clip = clip.subclip(0, max_clip_duration) clip = clip.subclipped(0, max_clip_duration)
clips.append(clip) # wirte clip to temp file
clip_file = f"{output_dir}/temp-clip-{i+1}.mp4"
clip.write_videofile(clip_file, logger=None, fps=fps, codec=video_codec)
close_clip(clip)
clip_files.append(clip_file)
video_duration += clip.duration video_duration += clip.duration
except Exception as e:
logger.error(f"failed to process clip: {str(e)}")
# merge video clips progressively, avoid loading all videos at once to avoid memory overflow
logger.info("starting clip merging process")
if not clip_files:
logger.warning("no clips available for merging")
return combined_video_path
# if there is only one clip, use it directly
if len(clip_files) == 1:
logger.info("using single clip directly")
shutil.copy(clip_files[0], combined_video_path)
delete_files(clip_files)
logger.info("video combining completed")
return combined_video_path
# create initial video file as base
base_clip_path = clip_files[0]
temp_merged_video = f"{output_dir}/temp-merged-video.mp4"
temp_merged_next = f"{output_dir}/temp-merged-next.mp4"
# copy first clip as initial merged video
shutil.copy(base_clip_path, temp_merged_video)
# merge remaining video clips one by one
for i, clip_path in enumerate(clip_files[1:], 1):
logger.info(f"merging clip {i}/{len(clip_files)-1}")
try:
# load current base video and next clip to merge
base_clip = VideoFileClip(temp_merged_video)
next_clip = VideoFileClip(clip_path)
# merge these two clips
merged_clip = concatenate_videoclips([base_clip, next_clip])
final_clip = concatenate_videoclips(clips) # save merged result to temp file
final_clip = final_clip.set_fps(30) merged_clip.write_videofile(
logger.info(f"writing") filename=temp_merged_next,
# https://github.com/harry0703/MoneyPrinterTurbo/issues/111#issuecomment-2032354030 threads=threads,
final_clip.write_videofile(filename=combined_video_path, logger=None,
threads=threads, temp_audiofile_path=output_dir,
logger=None, audio_codec=audio_codec,
temp_audiofile_path=output_dir, fps=fps,
audio_codec="aac", )
) close_clip(base_clip)
logger.success(f"completed") close_clip(next_clip)
close_clip(merged_clip)
# replace base file with new merged file
delete_files(temp_merged_video)
os.rename(temp_merged_next, temp_merged_video)
except Exception as e:
logger.error(f"failed to merge clip: {str(e)}")
continue
# after merging, rename final result to target file name
os.rename(temp_merged_video, combined_video_path)
# clean temp files
delete_files(clip_files)
logger.info("video combining completed")
return combined_video_path return combined_video_path
def wrap_text(text, max_width, font='Arial', fontsize=60): def wrap_text(text, max_width, font="Arial", fontsize=60):
# 创建字体对象 # Create ImageFont
font = ImageFont.truetype(font, fontsize) font = ImageFont.truetype(font, fontsize)
def get_text_size(inner_text): def get_text_size(inner_text):
@@ -126,13 +305,11 @@ def wrap_text(text, max_width, font='Arial', fontsize=60):
if width <= max_width: if width <= max_width:
return text, height return text, height
logger.warning(f"wrapping text, max_width: {max_width}, text_width: {width}, text: {text}")
processed = True processed = True
_wrapped_lines_ = [] _wrapped_lines_ = []
words = text.split(" ") words = text.split(" ")
_txt_ = '' _txt_ = ""
for word in words: for word in words:
_before = _txt_ _before = _txt_
_txt_ += f"{word} " _txt_ += f"{word} "
@@ -148,14 +325,13 @@ def wrap_text(text, max_width, font='Arial', fontsize=60):
_wrapped_lines_.append(_txt_) _wrapped_lines_.append(_txt_)
if processed: if processed:
_wrapped_lines_ = [line.strip() for line in _wrapped_lines_] _wrapped_lines_ = [line.strip() for line in _wrapped_lines_]
result = '\n'.join(_wrapped_lines_).strip() result = "\n".join(_wrapped_lines_).strip()
height = len(_wrapped_lines_) * height height = len(_wrapped_lines_) * height
logger.warning(f"wrapped text: {result}")
return result, height return result, height
_wrapped_lines_ = [] _wrapped_lines_ = []
chars = list(text) chars = list(text)
_txt_ = '' _txt_ = ""
for word in chars: for word in chars:
_txt_ += word _txt_ += word
_width, _height = get_text_size(_txt_) _width, _height = get_text_size(_txt_)
@@ -163,24 +339,24 @@ def wrap_text(text, max_width, font='Arial', fontsize=60):
continue continue
else: else:
_wrapped_lines_.append(_txt_) _wrapped_lines_.append(_txt_)
_txt_ = '' _txt_ = ""
_wrapped_lines_.append(_txt_) _wrapped_lines_.append(_txt_)
result = '\n'.join(_wrapped_lines_).strip() result = "\n".join(_wrapped_lines_).strip()
height = len(_wrapped_lines_) * height height = len(_wrapped_lines_) * height
logger.warning(f"wrapped text: {result}")
return result, height return result, height
def generate_video(video_path: str, def generate_video(
audio_path: str, video_path: str,
subtitle_path: str, audio_path: str,
output_file: str, subtitle_path: str,
params: VideoParams, output_file: str,
): params: VideoParams,
):
aspect = VideoAspect(params.video_aspect) aspect = VideoAspect(params.video_aspect)
video_width, video_height = aspect.to_resolution() video_width, video_height = aspect.to_resolution()
logger.info(f"start, video size: {video_width} x {video_height}") logger.info(f"generating video: {video_width} x {video_height}")
logger.info(f" ① video: {video_path}") logger.info(f" ① video: {video_path}")
logger.info(f" ② audio: {audio_path}") logger.info(f" ② audio: {audio_path}")
logger.info(f" ③ subtitle: {subtitle_path}") logger.info(f" ③ subtitle: {subtitle_path}")
@@ -196,46 +372,71 @@ def generate_video(video_path: str,
if not params.font_name: if not params.font_name:
params.font_name = "STHeitiMedium.ttc" params.font_name = "STHeitiMedium.ttc"
font_path = os.path.join(utils.font_dir(), params.font_name) font_path = os.path.join(utils.font_dir(), params.font_name)
if os.name == 'nt': if os.name == "nt":
font_path = font_path.replace("\\", "/") font_path = font_path.replace("\\", "/")
logger.info(f"using font: {font_path}") logger.info(f" font: {font_path}")
def create_text_clip(subtitle_item): def create_text_clip(subtitle_item):
params.font_size = int(params.font_size)
params.stroke_width = int(params.stroke_width)
phrase = subtitle_item[1] phrase = subtitle_item[1]
max_width = video_width * 0.9 max_width = video_width * 0.9
wrapped_txt, txt_height = wrap_text(phrase, wrapped_txt, txt_height = wrap_text(
max_width=max_width, phrase, max_width=max_width, font=font_path, fontsize=params.font_size
font=font_path, )
fontsize=params.font_size interline = int(params.font_size * 0.25)
) size=(int(max_width), int(txt_height + params.font_size * 0.25 + (interline * (wrapped_txt.count("\n") + 1))))
_clip = TextClip( _clip = TextClip(
wrapped_txt, text=wrapped_txt,
font=font_path, font=font_path,
fontsize=params.font_size, font_size=params.font_size,
color=params.text_fore_color, color=params.text_fore_color,
bg_color=params.text_background_color, bg_color=params.text_background_color,
stroke_color=params.stroke_color, stroke_color=params.stroke_color,
stroke_width=params.stroke_width, stroke_width=params.stroke_width,
print_cmd=False, # interline=interline,
# size=size,
) )
duration = subtitle_item[0][1] - subtitle_item[0][0] duration = subtitle_item[0][1] - subtitle_item[0][0]
_clip = _clip.set_start(subtitle_item[0][0]) _clip = _clip.with_start(subtitle_item[0][0])
_clip = _clip.set_end(subtitle_item[0][1]) _clip = _clip.with_end(subtitle_item[0][1])
_clip = _clip.set_duration(duration) _clip = _clip.with_duration(duration)
if params.subtitle_position == "bottom": if params.subtitle_position == "bottom":
_clip = _clip.set_position(('center', video_height * 0.95 - _clip.h)) _clip = _clip.with_position(("center", video_height * 0.95 - _clip.h))
elif params.subtitle_position == "top": elif params.subtitle_position == "top":
_clip = _clip.set_position(('center', video_height * 0.1)) _clip = _clip.with_position(("center", video_height * 0.05))
else: elif params.subtitle_position == "custom":
_clip = _clip.set_position(('center', 'center')) # Ensure the subtitle is fully within the screen bounds
margin = 10 # Additional margin, in pixels
max_y = video_height - _clip.h - margin
min_y = margin
custom_y = (video_height - _clip.h) * (params.custom_position / 100)
custom_y = max(
min_y, min(custom_y, max_y)
) # Constrain the y value within the valid range
_clip = _clip.with_position(("center", custom_y))
else: # center
_clip = _clip.with_position(("center", "center"))
return _clip return _clip
video_clip = VideoFileClip(video_path) video_clip = VideoFileClip(video_path).without_audio()
audio_clip = AudioFileClip(audio_path).volumex(params.voice_volume) audio_clip = AudioFileClip(audio_path).with_effects(
[afx.MultiplyVolume(params.voice_volume)]
)
def make_textclip(text):
return TextClip(
text=text,
font=font_path,
font_size=params.font_size,
)
if subtitle_path and os.path.exists(subtitle_path): if subtitle_path and os.path.exists(subtitle_path):
sub = SubtitlesClip(subtitles=subtitle_path, encoding='utf-8') sub = SubtitlesClip(
subtitles=subtitle_path, encoding="utf-8", make_textclip=make_textclip
)
text_clips = [] text_clips = []
for item in sub.subtitles: for item in sub.subtitles:
clip = create_text_clip(subtitle_item=item) clip = create_text_clip(subtitle_item=item)
@@ -244,71 +445,74 @@ def generate_video(video_path: str,
bgm_file = get_bgm_file(bgm_type=params.bgm_type, bgm_file=params.bgm_file) bgm_file = get_bgm_file(bgm_type=params.bgm_type, bgm_file=params.bgm_file)
if bgm_file: if bgm_file:
bgm_clip = (AudioFileClip(bgm_file) try:
.set_duration(video_clip.duration) bgm_clip = AudioFileClip(bgm_file).with_effects(
.volumex(params.bgm_volume) [
.audio_fadeout(3)) afx.MultiplyVolume(params.bgm_volume),
afx.AudioFadeOut(3),
afx.AudioLoop(duration=video_clip.duration),
]
)
audio_clip = CompositeAudioClip([audio_clip, bgm_clip])
except Exception as e:
logger.error(f"failed to add bgm: {str(e)}")
audio_clip = CompositeAudioClip([audio_clip, bgm_clip]) video_clip = video_clip.with_audio(audio_clip)
video_clip = video_clip.set_audio(audio_clip) video_clip.write_videofile(
video_clip.write_videofile(output_file, output_file,
audio_codec="aac", audio_codec=audio_codec,
temp_audiofile_path=output_dir, temp_audiofile_path=output_dir,
threads=params.n_threads or 2, threads=params.n_threads or 2,
logger=None) logger=None,
fps=fps,
logger.success(f"completed") )
video_clip.close()
del video_clip
if __name__ == "__main__": def preprocess_video(materials: List[MaterialInfo], clip_duration=4):
txt_en = "Here's your guide to travel hacks for budget-friendly adventures" for material in materials:
txt_zh = "测试长字段这是您的旅行技巧指南帮助您进行预算友好的冒险" if not material.url:
font = utils.resource_dir() + "/fonts/STHeitiMedium.ttc" continue
for txt in [txt_en, txt_zh]:
t, h = wrap_text(text=txt, max_width=1000, font=font, fontsize=60)
print(t)
task_id = "aa563149-a7ea-49c2-b39f-8c32cc225baf" ext = utils.parse_extension(material.url)
task_dir = utils.task_dir(task_id) try:
video_file = f"{task_dir}/combined-1.mp4" clip = VideoFileClip(material.url)
audio_file = f"{task_dir}/audio.mp3" except Exception:
subtitle_file = f"{task_dir}/subtitle.srt" clip = ImageClip(material.url)
output_file = f"{task_dir}/final.mp4"
# video_paths = [] width = clip.size[0]
# for file in os.listdir(utils.storage_dir("test")): height = clip.size[1]
# if file.endswith(".mp4"): if width < 480 or height < 480:
# video_paths.append(os.path.join(utils.storage_dir("test"), file)) logger.warning(f"low resolution material: {width}x{height}, minimum 480x480 required")
# continue
# combine_videos(combined_video_path=video_file,
# audio_file=audio_file,
# video_paths=video_paths,
# video_aspect=VideoAspect.portrait,
# video_concat_mode=VideoConcatMode.random,
# max_clip_duration=5,
# threads=2)
cfg = VideoParams() if ext in const.FILE_TYPE_IMAGES:
cfg.video_aspect = VideoAspect.portrait logger.info(f"processing image: {material.url}")
cfg.font_name = "STHeitiMedium.ttc" # Create an image clip and set its duration to 3 seconds
cfg.font_size = 60 clip = (
cfg.stroke_color = "#000000" ImageClip(material.url)
cfg.stroke_width = 1.5 .with_duration(clip_duration)
cfg.text_fore_color = "#FFFFFF" .with_position("center")
cfg.text_background_color = "transparent" )
cfg.bgm_type = "random" # Apply a zoom effect using the resize method.
cfg.bgm_file = "" # A lambda function is used to make the zoom effect dynamic over time.
cfg.bgm_volume = 1.0 # The zoom effect starts from the original size and gradually scales up to 120%.
cfg.subtitle_enabled = True # t represents the current time, and clip.duration is the total duration of the clip (3 seconds).
cfg.subtitle_position = "bottom" # Note: 1 represents 100% size, so 1.2 represents 120% size.
cfg.n_threads = 2 zoom_clip = clip.resized(
cfg.paragraph_number = 1 lambda t: 1 + (clip_duration * 0.03) * (t / clip.duration)
)
cfg.voice_volume = 1.0 # Optionally, create a composite video clip containing the zoomed clip.
# This is useful when you want to add other elements to the video.
final_clip = CompositeVideoClip([zoom_clip])
generate_video(video_path=video_file, # Output the video to a file.
audio_path=audio_file, video_file = f"{material.url}.mp4"
subtitle_path=subtitle_file, final_clip.write_videofile(video_file, fps=30, logger=None)
output_file=output_file, final_clip.close()
params=cfg del final_clip
) material.url = video_file
logger.success(f"image processed: {video_file}")
return materials

View File

@@ -1,19 +1,21 @@
import asyncio import asyncio
import os import os
import re import re
from datetime import datetime
from typing import Union
from xml.sax.saxutils import unescape from xml.sax.saxutils import unescape
import edge_tts
from edge_tts import SubMaker, submaker
from edge_tts.submaker import mktimestamp from edge_tts.submaker import mktimestamp
from loguru import logger from loguru import logger
from edge_tts import submaker, SubMaker
import edge_tts
from moviepy.video.tools import subtitles from moviepy.video.tools import subtitles
from app.config import config
from app.utils import utils from app.utils import utils
def get_all_voices(filter_locals=None) -> list[str]: def get_all_azure_voices(filter_locals=None) -> list[str]:
if filter_locals is None:
filter_locals = ["zh-CN", "en-US", "zh-HK", "zh-TW"]
voices_str = """ voices_str = """
Name: af-ZA-AdriNeural Name: af-ZA-AdriNeural
Gender: Female Gender: Female
@@ -300,21 +302,33 @@ Gender: Female
Name: en-US-AnaNeural Name: en-US-AnaNeural
Gender: Female Gender: Female
Name: en-US-AndrewMultilingualNeural
Gender: Male
Name: en-US-AndrewNeural Name: en-US-AndrewNeural
Gender: Male Gender: Male
Name: en-US-AriaNeural Name: en-US-AriaNeural
Gender: Female Gender: Female
Name: en-US-AvaMultilingualNeural
Gender: Female
Name: en-US-AvaNeural Name: en-US-AvaNeural
Gender: Female Gender: Female
Name: en-US-BrianMultilingualNeural
Gender: Male
Name: en-US-BrianNeural Name: en-US-BrianNeural
Gender: Male Gender: Male
Name: en-US-ChristopherNeural Name: en-US-ChristopherNeural
Gender: Male Gender: Male
Name: en-US-EmmaMultilingualNeural
Gender: Female
Name: en-US-EmmaNeural Name: en-US-EmmaNeural
Gender: Female Gender: Female
@@ -600,12 +614,24 @@ Gender: Male
Name: it-IT-ElsaNeural Name: it-IT-ElsaNeural
Gender: Female Gender: Female
Name: it-IT-GiuseppeNeural Name: it-IT-GiuseppeMultilingualNeural
Gender: Male Gender: Male
Name: it-IT-IsabellaNeural Name: it-IT-IsabellaNeural
Gender: Female Gender: Female
Name: iu-Cans-CA-SiqiniqNeural
Gender: Female
Name: iu-Cans-CA-TaqqiqNeural
Gender: Male
Name: iu-Latn-CA-SiqiniqNeural
Gender: Female
Name: iu-Latn-CA-TaqqiqNeural
Gender: Male
Name: ja-JP-KeitaNeural Name: ja-JP-KeitaNeural
Gender: Male Gender: Male
@@ -642,7 +668,7 @@ Gender: Male
Name: kn-IN-SapnaNeural Name: kn-IN-SapnaNeural
Gender: Female Gender: Female
Name: ko-KR-HyunsuNeural Name: ko-KR-HyunsuMultilingualNeural
Gender: Male Gender: Male
Name: ko-KR-InJoonNeural Name: ko-KR-InJoonNeural
@@ -756,7 +782,7 @@ Gender: Male
Name: pt-BR-FranciscaNeural Name: pt-BR-FranciscaNeural
Gender: Female Gender: Female
Name: pt-BR-ThalitaNeural Name: pt-BR-ThalitaMultilingualNeural
Gender: Female Gender: Female
Name: pt-PT-DuarteNeural Name: pt-PT-DuarteNeural
@@ -956,29 +982,50 @@ Gender: Female
Name: zu-ZA-ThembaNeural Name: zu-ZA-ThembaNeural
Gender: Male Gender: Male
Name: en-US-AvaMultilingualNeural-V2
Gender: Female
Name: en-US-AndrewMultilingualNeural-V2
Gender: Male
Name: en-US-EmmaMultilingualNeural-V2
Gender: Female
Name: en-US-BrianMultilingualNeural-V2
Gender: Male
Name: de-DE-FlorianMultilingualNeural-V2
Gender: Male
Name: de-DE-SeraphinaMultilingualNeural-V2
Gender: Female
Name: fr-FR-RemyMultilingualNeural-V2
Gender: Male
Name: fr-FR-VivienneMultilingualNeural-V2
Gender: Female
Name: zh-CN-XiaoxiaoMultilingualNeural-V2
Gender: Female
""".strip() """.strip()
voices = [] voices = []
name = '' # 定义正则表达式模式,用于匹配 Name 和 Gender 行
for line in voices_str.split("\n"): pattern = re.compile(r"Name:\s*(.+)\s*Gender:\s*(.+)\s*", re.MULTILINE)
line = line.strip() # 使用正则表达式查找所有匹配项
if not line: matches = pattern.findall(voices_str)
continue
if line.startswith("Name: "): for name, gender in matches:
name = line[6:].strip() # 应用过滤条件
if line.startswith("Gender: "): if filter_locals and any(
gender = line[8:].strip() name.lower().startswith(fl.lower()) for fl in filter_locals
if name and gender: ):
# voices.append({ voices.append(f"{name}-{gender}")
# "name": name, elif not filter_locals:
# "gender": gender, voices.append(f"{name}-{gender}")
# })
if filter_locals:
for filter_local in filter_locals:
if name.lower().startswith(filter_local.lower()):
voices.append(f"{name}-{gender}")
else:
voices.append(f"{name}-{gender}")
name = ''
voices.sort() voices.sort()
return voices return voices
@@ -986,30 +1033,62 @@ Gender: Male
def parse_voice_name(name: str): def parse_voice_name(name: str):
# zh-CN-XiaoyiNeural-Female # zh-CN-XiaoyiNeural-Female
# zh-CN-YunxiNeural-Male # zh-CN-YunxiNeural-Male
# zh-CN-XiaoxiaoMultilingualNeural-V2-Female
name = name.replace("-Female", "").replace("-Male", "").strip() name = name.replace("-Female", "").replace("-Male", "").strip()
return name return name
def tts(text: str, voice_name: str, voice_file: str) -> [SubMaker, None]: def is_azure_v2_voice(voice_name: str):
voice_name = parse_voice_name(voice_name)
if voice_name.endswith("-V2"):
return voice_name.replace("-V2", "").strip()
return ""
def tts(
text: str, voice_name: str, voice_rate: float, voice_file: str
) -> Union[SubMaker, None]:
if is_azure_v2_voice(voice_name):
return azure_tts_v2(text, voice_name, voice_file)
return azure_tts_v1(text, voice_name, voice_rate, voice_file)
def convert_rate_to_percent(rate: float) -> str:
if rate == 1.0:
return "+0%"
percent = round((rate - 1.0) * 100)
if percent > 0:
return f"+{percent}%"
else:
return f"{percent}%"
def azure_tts_v1(
text: str, voice_name: str, voice_rate: float, voice_file: str
) -> Union[SubMaker, None]:
voice_name = parse_voice_name(voice_name)
text = text.strip() text = text.strip()
rate_str = convert_rate_to_percent(voice_rate)
for i in range(3): for i in range(3):
try: try:
logger.info(f"start, voice name: {voice_name}, try: {i + 1}") logger.info(f"start, voice name: {voice_name}, try: {i + 1}")
async def _do() -> SubMaker: async def _do() -> SubMaker:
communicate = edge_tts.Communicate(text, voice_name) communicate = edge_tts.Communicate(text, voice_name, rate=rate_str)
sub_maker = edge_tts.SubMaker() sub_maker = edge_tts.SubMaker()
with open(voice_file, "wb") as file: with open(voice_file, "wb") as file:
async for chunk in communicate.stream(): async for chunk in communicate.stream():
if chunk["type"] == "audio": if chunk["type"] == "audio":
file.write(chunk["data"]) file.write(chunk["data"])
elif chunk["type"] == "WordBoundary": elif chunk["type"] == "WordBoundary":
sub_maker.create_sub((chunk["offset"], chunk["duration"]), chunk["text"]) sub_maker.create_sub(
(chunk["offset"], chunk["duration"]), chunk["text"]
)
return sub_maker return sub_maker
sub_maker = asyncio.run(_do()) sub_maker = asyncio.run(_do())
if not sub_maker or not sub_maker.subs: if not sub_maker or not sub_maker.subs:
logger.warning(f"failed, sub_maker is None or sub_maker.subs is None") logger.warning("failed, sub_maker is None or sub_maker.subs is None")
continue continue
logger.info(f"completed, output file: {voice_file}") logger.info(f"completed, output file: {voice_file}")
@@ -1019,14 +1098,99 @@ def tts(text: str, voice_name: str, voice_file: str) -> [SubMaker, None]:
return None return None
def create_subtitle(sub_maker: submaker.SubMaker, text: str, subtitle_file: str): def azure_tts_v2(text: str, voice_name: str, voice_file: str) -> Union[SubMaker, None]:
""" voice_name = is_azure_v2_voice(voice_name)
优化字幕文件 if not voice_name:
1. 将字幕文件按照标点符号分割成多行 logger.error(f"invalid voice name: {voice_name}")
2. 逐行匹配字幕文件中的文本 raise ValueError(f"invalid voice name: {voice_name}")
3. 生成新的字幕文件 text = text.strip()
"""
text = text.replace("\n", " ") def _format_duration_to_offset(duration) -> int:
if isinstance(duration, str):
time_obj = datetime.strptime(duration, "%H:%M:%S.%f")
milliseconds = (
(time_obj.hour * 3600000)
+ (time_obj.minute * 60000)
+ (time_obj.second * 1000)
+ (time_obj.microsecond // 1000)
)
return milliseconds * 10000
if isinstance(duration, int):
return duration
return 0
for i in range(3):
try:
logger.info(f"start, voice name: {voice_name}, try: {i + 1}")
import azure.cognitiveservices.speech as speechsdk
sub_maker = SubMaker()
def speech_synthesizer_word_boundary_cb(evt: speechsdk.SessionEventArgs):
# print('WordBoundary event:')
# print('\tBoundaryType: {}'.format(evt.boundary_type))
# print('\tAudioOffset: {}ms'.format((evt.audio_offset + 5000)))
# print('\tDuration: {}'.format(evt.duration))
# print('\tText: {}'.format(evt.text))
# print('\tTextOffset: {}'.format(evt.text_offset))
# print('\tWordLength: {}'.format(evt.word_length))
duration = _format_duration_to_offset(str(evt.duration))
offset = _format_duration_to_offset(evt.audio_offset)
sub_maker.subs.append(evt.text)
sub_maker.offset.append((offset, offset + duration))
# Creates an instance of a speech config with specified subscription key and service region.
speech_key = config.azure.get("speech_key", "")
service_region = config.azure.get("speech_region", "")
audio_config = speechsdk.audio.AudioOutputConfig(
filename=voice_file, use_default_speaker=True
)
speech_config = speechsdk.SpeechConfig(
subscription=speech_key, region=service_region
)
speech_config.speech_synthesis_voice_name = voice_name
# speech_config.set_property(property_id=speechsdk.PropertyId.SpeechServiceResponse_RequestSentenceBoundary,
# value='true')
speech_config.set_property(
property_id=speechsdk.PropertyId.SpeechServiceResponse_RequestWordBoundary,
value="true",
)
speech_config.set_speech_synthesis_output_format(
speechsdk.SpeechSynthesisOutputFormat.Audio48Khz192KBitRateMonoMp3
)
speech_synthesizer = speechsdk.SpeechSynthesizer(
audio_config=audio_config, speech_config=speech_config
)
speech_synthesizer.synthesis_word_boundary.connect(
speech_synthesizer_word_boundary_cb
)
result = speech_synthesizer.speak_text_async(text).get()
if result.reason == speechsdk.ResultReason.SynthesizingAudioCompleted:
logger.success(f"azure v2 speech synthesis succeeded: {voice_file}")
return sub_maker
elif result.reason == speechsdk.ResultReason.Canceled:
cancellation_details = result.cancellation_details
logger.error(
f"azure v2 speech synthesis canceled: {cancellation_details.reason}"
)
if cancellation_details.reason == speechsdk.CancellationReason.Error:
logger.error(
f"azure v2 speech synthesis error: {cancellation_details.error_details}"
)
logger.info(f"completed, output file: {voice_file}")
except Exception as e:
logger.error(f"failed, error: {str(e)}")
return None
def _format_text(text: str) -> str:
# text = text.replace("\n", " ")
text = text.replace("[", " ") text = text.replace("[", " ")
text = text.replace("]", " ") text = text.replace("]", " ")
text = text.replace("(", " ") text = text.replace("(", " ")
@@ -1034,6 +1198,18 @@ def create_subtitle(sub_maker: submaker.SubMaker, text: str, subtitle_file: str)
text = text.replace("{", " ") text = text.replace("{", " ")
text = text.replace("}", " ") text = text.replace("}", " ")
text = text.strip() text = text.strip()
return text
def create_subtitle(sub_maker: submaker.SubMaker, text: str, subtitle_file: str):
"""
优化字幕文件
1. 将字幕文件按照标点符号分割成多行
2. 逐行匹配字幕文件中的文本
3. 生成新的字幕文件
"""
text = _format_text(text)
def formatter(idx: int, start_time: float, end_time: float, sub_text: str) -> str: def formatter(idx: int, start_time: float, end_time: float, sub_text: str) -> str:
""" """
@@ -1043,11 +1219,7 @@ def create_subtitle(sub_maker: submaker.SubMaker, text: str, subtitle_file: str)
""" """
start_t = mktimestamp(start_time).replace(".", ",") start_t = mktimestamp(start_time).replace(".", ",")
end_t = mktimestamp(end_time).replace(".", ",") end_t = mktimestamp(end_time).replace(".", ",")
return ( return f"{idx}\n" f"{start_t} --> {end_t}\n" f"{sub_text}\n"
f"{idx}\n"
f"{start_t} --> {end_t}\n"
f"{sub_text}\n"
)
start_time = -1.0 start_time = -1.0
sub_items = [] sub_items = []
@@ -1104,12 +1276,16 @@ def create_subtitle(sub_maker: submaker.SubMaker, text: str, subtitle_file: str)
try: try:
sbs = subtitles.file_to_subtitles(subtitle_file, encoding="utf-8") sbs = subtitles.file_to_subtitles(subtitle_file, encoding="utf-8")
duration = max([tb for ((ta, tb), txt) in sbs]) duration = max([tb for ((ta, tb), txt) in sbs])
logger.info(f"completed, subtitle file created: {subtitle_file}, duration: {duration}") logger.info(
f"completed, subtitle file created: {subtitle_file}, duration: {duration}"
)
except Exception as e: except Exception as e:
logger.error(f"failed, error: {str(e)}") logger.error(f"failed, error: {str(e)}")
os.remove(subtitle_file) os.remove(subtitle_file)
else: else:
logger.warning(f"failed, sub_items len: {len(sub_items)}, script_lines len: {len(script_lines)}") logger.warning(
f"failed, sub_items len: {len(sub_items)}, script_lines len: {len(script_lines)}"
)
except Exception as e: except Exception as e:
logger.error(f"failed, error: {str(e)}") logger.error(f"failed, error: {str(e)}")
@@ -1125,15 +1301,19 @@ def get_audio_duration(sub_maker: submaker.SubMaker):
if __name__ == "__main__": if __name__ == "__main__":
voices = get_all_voices() voice_name = "zh-CN-XiaoxiaoMultilingualNeural-V2-Female"
print(voices) voice_name = parse_voice_name(voice_name)
print(len(voices)) voice_name = is_azure_v2_voice(voice_name)
print(voice_name)
voices = get_all_azure_voices()
print(len(voices))
async def _do(): async def _do():
temp_dir = utils.storage_dir("temp") temp_dir = utils.storage_dir("temp")
voice_names = [ voice_names = [
"zh-CN-XiaoxiaoMultilingualNeural",
# 女性 # 女性
"zh-CN-XiaoxiaoNeural", "zh-CN-XiaoxiaoNeural",
"zh-CN-XiaoyiNeural", "zh-CN-XiaoyiNeural",
@@ -1156,15 +1336,34 @@ if __name__ == "__main__":
""" """
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.]" 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.]"
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用卡技巧》让我们一起成为用卡高手"
text = """
2023全年业绩速览
公司全年累计实现营业收入1476.94亿元同比增长19.01%归母净利润747.34亿元同比增长19.16%。EPS达到59.49元。第四季度单季营业收入444.25亿元同比增长20.26%环比增长31.86%归母净利润218.58亿元同比增长19.33%环比增长29.37%。这一阶段
的业绩表现不仅突显了公司的增长动力和盈利能力,也反映出公司在竞争激烈的市场环境中保持了良好的发展势头。
2023年Q4业绩速览
第四季度营业收入贡献主要增长点销售费用高增致盈利能力承压税金同比上升27%,扰动净利率表现。
业绩解读
利润方面2023全年贵州茅台>归母净利润增速为19%其中营业收入正贡献18%,营业成本正贡献百分之一,管理费用正贡献百分之一点四。(注:归母净利润增速值=营业收入增速+各科目贡献,展示贡献/拖累的前四名科目,且要求贡献值/净利润增速>15%)
"""
text = "静夜思是唐代诗人李白创作的一首五言古诗。这首诗描绘了诗人在寂静的夜晚,看到窗前的明月,不禁想起远方的家乡和亲人"
text = _format_text(text)
lines = utils.split_string_by_punctuations(text)
print(lines)
for voice_name in voice_names: for voice_name in voice_names:
voice_file = f"{temp_dir}/tts-{voice_name}.mp3" voice_file = f"{temp_dir}/tts-{voice_name}.mp3"
subtitle_file = f"{temp_dir}/tts.mp3.srt" subtitle_file = f"{temp_dir}/tts.mp3.srt"
sub_maker = tts(text=text, voice_name=voice_name, voice_file=voice_file) sub_maker = azure_tts_v2(
text=text, voice_name=voice_name, voice_file=voice_file
)
create_subtitle(sub_maker=sub_maker, text=text, subtitle_file=subtitle_file) create_subtitle(sub_maker=sub_maker, text=text, subtitle_file=subtitle_file)
audio_duration = get_audio_duration(sub_maker) audio_duration = get_audio_duration(sub_maker)
print(f"voice: {voice_name}, audio duration: {audio_duration}s") print(f"voice: {voice_name}, audio duration: {audio_duration}s")
loop = asyncio.get_event_loop_policy().get_event_loop() loop = asyncio.get_event_loop_policy().get_event_loop()
try: try:
loop.run_until_complete(_do()) loop.run_until_complete(_do())

View File

@@ -1,12 +1,12 @@
import json
import locale import locale
import os import os
import platform
import threading import threading
from typing import Any from typing import Any
from loguru import logger
import json
from uuid import uuid4 from uuid import uuid4
import urllib3 import urllib3
from loguru import logger
from app.models import const from app.models import const
@@ -15,44 +15,44 @@ urllib3.disable_warnings()
def get_response(status: int, data: Any = None, message: str = ""): def get_response(status: int, data: Any = None, message: str = ""):
obj = { obj = {
'status': status, "status": status,
} }
if data: if data:
obj['data'] = data obj["data"] = data
if message: if message:
obj['message'] = message obj["message"] = message
return obj return obj
def to_json(obj): def to_json(obj):
try: try:
# 定义一个辅助函数来处理不同类型的对象 # Define a helper function to handle different types of objects
def serialize(o): def serialize(o):
# 如果对象是可序列化类型,直接返回 # If the object is a serializable type, return it directly
if isinstance(o, (int, float, bool, str)) or o is None: if isinstance(o, (int, float, bool, str)) or o is None:
return o return o
# 如果对象是二进制数据转换为base64编码的字符串 # If the object is binary data, convert it to a base64-encoded string
elif isinstance(o, bytes): elif isinstance(o, bytes):
return "*** binary data ***" return "*** binary data ***"
# 如果对象是字典,递归处理每个键值对 # If the object is a dictionary, recursively process each key-value pair
elif isinstance(o, dict): elif isinstance(o, dict):
return {k: serialize(v) for k, v in o.items()} return {k: serialize(v) for k, v in o.items()}
# 如果对象是列表或元组,递归处理每个元素 # If the object is a list or tuple, recursively process each element
elif isinstance(o, (list, tuple)): elif isinstance(o, (list, tuple)):
return [serialize(item) for item in o] return [serialize(item) for item in o]
# 如果对象是自定义类型尝试返回其__dict__属性 # If the object is a custom type, attempt to return its __dict__ attribute
elif hasattr(o, '__dict__'): elif hasattr(o, "__dict__"):
return serialize(o.__dict__) return serialize(o.__dict__)
# 其他情况返回None或者可以选择抛出异常 # Return None for other cases (or choose to raise an exception)
else: else:
return None return None
# 使用serialize函数处理输入对象 # Use the serialize function to process the input object
serialized_obj = serialize(obj) serialized_obj = serialize(obj)
# 序列化处理后的对象为JSON字符串 # Serialize the processed object into a JSON string
return json.dumps(serialized_obj, ensure_ascii=False, indent=4) return json.dumps(serialized_obj, ensure_ascii=False, indent=4)
except Exception as e: except Exception:
return None return None
@@ -67,10 +67,13 @@ def root_dir():
return os.path.dirname(os.path.dirname(os.path.dirname(os.path.realpath(__file__)))) return os.path.dirname(os.path.dirname(os.path.dirname(os.path.realpath(__file__))))
def storage_dir(sub_dir: str = ""): def storage_dir(sub_dir: str = "", create: bool = False):
d = os.path.join(root_dir(), "storage") d = os.path.join(root_dir(), "storage")
if sub_dir: if sub_dir:
d = os.path.join(d, sub_dir) d = os.path.join(d, sub_dir)
if create and not os.path.exists(d):
os.makedirs(d)
return d return d
@@ -91,7 +94,7 @@ def task_dir(sub_dir: str = ""):
def font_dir(sub_dir: str = ""): def font_dir(sub_dir: str = ""):
d = resource_dir(f"fonts") d = resource_dir("fonts")
if sub_dir: if sub_dir:
d = os.path.join(d, sub_dir) d = os.path.join(d, sub_dir)
if not os.path.exists(d): if not os.path.exists(d):
@@ -100,7 +103,7 @@ def font_dir(sub_dir: str = ""):
def song_dir(sub_dir: str = ""): def song_dir(sub_dir: str = ""):
d = resource_dir(f"songs") d = resource_dir("songs")
if sub_dir: if sub_dir:
d = os.path.join(d, sub_dir) d = os.path.join(d, sub_dir)
if not os.path.exists(d): if not os.path.exists(d):
@@ -109,7 +112,7 @@ def song_dir(sub_dir: str = ""):
def public_dir(sub_dir: str = ""): def public_dir(sub_dir: str = ""):
d = resource_dir(f"public") d = resource_dir("public")
if sub_dir: if sub_dir:
d = os.path.join(d, sub_dir) d = os.path.join(d, sub_dir)
if not os.path.exists(d): if not os.path.exists(d):
@@ -163,18 +166,41 @@ def str_contains_punctuation(word):
def split_string_by_punctuations(s): def split_string_by_punctuations(s):
result = [] result = []
txt = "" txt = ""
for char in s:
previous_char = ""
next_char = ""
for i in range(len(s)):
char = s[i]
if char == "\n":
result.append(txt.strip())
txt = ""
continue
if i > 0:
previous_char = s[i - 1]
if i < len(s) - 1:
next_char = s[i + 1]
if char == "." and previous_char.isdigit() and next_char.isdigit():
# # 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
txt += char
continue
if char not in const.PUNCTUATIONS: if char not in const.PUNCTUATIONS:
txt += char txt += char
else: else:
result.append(txt.strip()) result.append(txt.strip())
txt = "" txt = ""
result.append(txt.strip())
# filter empty string
result = list(filter(None, result))
return result return result
def md5(text): def md5(text):
import hashlib import hashlib
return hashlib.md5(text.encode('utf-8')).hexdigest()
return hashlib.md5(text.encode("utf-8")).hexdigest()
def get_system_locale(): def get_system_locale():
@@ -184,7 +210,7 @@ def get_system_locale():
# en_US, en_GB return en # en_US, en_GB return en
language_code = loc[0].split("_")[0] language_code = loc[0].split("_")[0]
return language_code return language_code
except Exception as e: except Exception:
return "en" return "en"
@@ -197,3 +223,7 @@ def load_locales(i18n_dir):
with open(os.path.join(root, file), "r", encoding="utf-8") as f: with open(os.path.join(root, file), "r", encoding="utf-8") as f:
_locales[lang] = json.loads(f.read()) _locales[lang] = json.loads(f.read())
return _locales return _locales
def parse_extension(filename):
return os.path.splitext(filename)[1].strip().lower().replace(".", "")

17
changelog.py Normal file
View File

@@ -0,0 +1,17 @@
from git_changelog.cli import build_and_render
# 运行这段脚本自动生成CHANGELOG.md文件
build_and_render(
repository=".",
output="CHANGELOG.md",
convention="angular",
provider="github",
template="keepachangelog",
parse_trailers=True,
parse_refs=False,
sections=["build", "deps", "feat", "fix", "refactor"],
versioning="pep440",
bump="1.1.2", # 指定bump版本
in_place=True,
)

View File

@@ -1,164 +1,200 @@
[app] [app]
# Pexels API Key video_source = "pexels" # "pexels" or "pixabay"
# Register at https://www.pexels.com/api/ to get your API key.
# You can use multiple keys to avoid rate limits.
# For example: pexels_api_keys = ["123adsf4567adf89","abd1321cd13efgfdfhi"]
# 特别注意格式Key 用英文双引号括起来多个Key用逗号隔开
pexels_api_keys = []
# 如果你没有 OPENAI API Key可以使用 g4f 代替,或者使用国内的 Moonshot API # 是否隐藏配置面板
# If you don't have an OPENAI API Key, you can use g4f instead hide_config = false
# 支持的提供商 (Supported providers): # Pexels API Key
# openai # Register at https://www.pexels.com/api/ to get your API key.
# moonshot (月之暗面) # You can use multiple keys to avoid rate limits.
# oneapi # For example: pexels_api_keys = ["123adsf4567adf89","abd1321cd13efgfdfhi"]
# g4f # 特别注意格式Key 用英文双引号括起来多个Key用逗号隔开
# azure pexels_api_keys = []
# qwen (通义千问)
# gemini
llm_provider="openai"
########## Ollama Settings # Pixabay API Key
# No need to set it unless you want to use your own proxy # Register at https://pixabay.com/api/docs/ to get your API key.
ollama_base_url = "" # You can use multiple keys to avoid rate limits.
# Check your available models at https://ollama.com/library # For example: pixabay_api_keys = ["123adsf4567adf89","abd1321cd13efgfdfhi"]
ollama_model_name = "" # 特别注意格式Key 用英文双引号括起来多个Key用逗号隔开
pixabay_api_keys = []
########## OpenAI API Key # 支持的提供商 (Supported providers):
# Get your API key at https://platform.openai.com/api-keys # openai
openai_api_key = "" # moonshot (月之暗面)
# No need to set it unless you want to use your own proxy # azure
openai_base_url = "" # qwen (通义千问)
# Check your available models at https://platform.openai.com/account/limits # deepseek
openai_model_name = "gpt-4-turbo-preview" # gemini
# ollama
# g4f
# oneapi
# cloudflare
# ernie (文心一言)
llm_provider = "openai"
########## Moonshot API Key ########## Ollama Settings
# Visit https://platform.moonshot.cn/console/api-keys to get your API key. # No need to set it unless you want to use your own proxy
moonshot_api_key="" ollama_base_url = ""
moonshot_base_url = "https://api.moonshot.cn/v1" # Check your available models at https://ollama.com/library
moonshot_model_name = "moonshot-v1-8k" ollama_model_name = ""
########## OneAPI API Key ########## OpenAI API Key
# Visit https://github.com/songquanpeng/one-api to get your API key # Get your API key at https://platform.openai.com/api-keys
oneapi_api_key="" openai_api_key = ""
oneapi_base_url="" # No need to set it unless you want to use your own proxy
oneapi_model_name="" openai_base_url = ""
# Check your available models at https://platform.openai.com/account/limits
openai_model_name = "gpt-4o-mini"
########## G4F ########## Moonshot API Key
# Visit https://github.com/xtekky/gpt4free to get more details # Visit https://platform.moonshot.cn/console/api-keys to get your API key.
# Supported model list: https://github.com/xtekky/gpt4free/blob/main/g4f/models.py moonshot_api_key = ""
g4f_model_name = "gpt-3.5-turbo-16k-0613" moonshot_base_url = "https://api.moonshot.cn/v1"
moonshot_model_name = "moonshot-v1-8k"
########## Azure API Key ########## OneAPI API Key
# Visit https://learn.microsoft.com/zh-cn/azure/ai-services/openai/ to get more details # Visit https://github.com/songquanpeng/one-api to get your API key
# API documentation: https://learn.microsoft.com/zh-cn/azure/ai-services/openai/reference oneapi_api_key = ""
azure_api_key = "" oneapi_base_url = ""
azure_base_url="" oneapi_model_name = ""
azure_model_name="gpt-35-turbo" # replace with your model deployment name
azure_api_version = "2024-02-15-preview"
########## Gemini API Key ########## G4F
gemini_api_key="" # Visit https://github.com/xtekky/gpt4free to get more details
gemini_model_name = "gemini-1.0-pro" # Supported model list: https://github.com/xtekky/gpt4free/blob/main/g4f/models.py
g4f_model_name = "gpt-3.5-turbo"
########## Qwen API Key ########## Azure API Key
# Visit https://dashscope.console.aliyun.com/apiKey to get your API key # Visit https://learn.microsoft.com/zh-cn/azure/ai-services/openai/ to get more details
# Visit below links to get more details # API documentation: https://learn.microsoft.com/zh-cn/azure/ai-services/openai/reference
# https://tongyi.aliyun.com/qianwen/ azure_api_key = ""
# https://help.aliyun.com/zh/dashscope/developer-reference/model-introduction azure_base_url = ""
qwen_api_key = "" azure_model_name = "gpt-35-turbo" # replace with your model deployment name
qwen_model_name = "qwen-max" azure_api_version = "2024-02-15-preview"
########## Gemini API Key
gemini_api_key = ""
gemini_model_name = "gemini-1.0-pro"
########## Qwen API Key
# Visit https://dashscope.console.aliyun.com/apiKey to get your API key
# Visit below links to get more details
# https://tongyi.aliyun.com/qianwen/
# https://help.aliyun.com/zh/dashscope/developer-reference/model-introduction
qwen_api_key = ""
qwen_model_name = "qwen-max"
# Subtitle Provider, "edge" or "whisper" ########## DeepSeek API Key
# If empty, the subtitle will not be generated # Visit https://platform.deepseek.com/api_keys to get your API key
subtitle_provider = "edge" deepseek_api_key = ""
deepseek_base_url = "https://api.deepseek.com"
deepseek_model_name = "deepseek-chat"
# # Subtitle Provider, "edge" or "whisper"
# ImageMagick # If empty, the subtitle will not be generated
# subtitle_provider = "edge"
# Once you have installed it, ImageMagick will be automatically detected, except on Windows!
# On Windows, for example "C:\Program Files (x86)\ImageMagick-7.1.1-Q16-HDRI\magick.exe"
# Download from https://imagemagick.org/archive/binaries/ImageMagick-7.1.1-29-Q16-x64-static.exe
# imagemagick_path = "C:\\Program Files (x86)\\ImageMagick-7.1.1-Q16\\magick.exe" #
# ImageMagick
#
# Once you have installed it, ImageMagick will be automatically detected, except on Windows!
# On Windows, for example "C:\Program Files (x86)\ImageMagick-7.1.1-Q16-HDRI\magick.exe"
# Download from https://imagemagick.org/archive/binaries/ImageMagick-7.1.1-29-Q16-x64-static.exe
# imagemagick_path = "C:\\Program Files (x86)\\ImageMagick-7.1.1-Q16\\magick.exe"
# #
# FFMPEG # FFMPEG
# #
# 通常情况下ffmpeg 会被自动下载,并且会被自动检测到。 # 通常情况下ffmpeg 会被自动下载,并且会被自动检测到。
# 但是如果你的环境有问题,无法自动下载,可能会遇到如下错误: # 但是如果你的环境有问题,无法自动下载,可能会遇到如下错误:
# RuntimeError: No ffmpeg exe could be found. # RuntimeError: No ffmpeg exe could be found.
# Install ffmpeg on your system, or set the IMAGEIO_FFMPEG_EXE environment variable. # Install ffmpeg on your system, or set the IMAGEIO_FFMPEG_EXE environment variable.
# 此时你可以手动下载 ffmpeg 并设置 ffmpeg_path下载地址https://www.gyan.dev/ffmpeg/builds/ # 此时你可以手动下载 ffmpeg 并设置 ffmpeg_path下载地址https://www.gyan.dev/ffmpeg/builds/
# Under normal circumstances, ffmpeg is downloaded automatically and detected automatically. # Under normal circumstances, ffmpeg is downloaded automatically and detected automatically.
# However, if there is an issue with your environment that prevents automatic downloading, you might encounter the following error: # However, if there is an issue with your environment that prevents automatic downloading, you might encounter the following error:
# RuntimeError: No ffmpeg exe could be found. # RuntimeError: No ffmpeg exe could be found.
# Install ffmpeg on your system, or set the IMAGEIO_FFMPEG_EXE environment variable. # Install ffmpeg on your system, or set the IMAGEIO_FFMPEG_EXE environment variable.
# In such cases, you can manually download ffmpeg and set the ffmpeg_path, download link: https://www.gyan.dev/ffmpeg/builds/ # In such cases, you can manually download ffmpeg and set the ffmpeg_path, download link: https://www.gyan.dev/ffmpeg/builds/
# ffmpeg_path = "C:\\Users\\harry\\Downloads\\ffmpeg.exe" # ffmpeg_path = "C:\\Users\\harry\\Downloads\\ffmpeg.exe"
######################################################################################### #########################################################################################
# 当视频生成成功后API服务提供的视频下载接入点默认为当前服务的地址和监听端口 # 当视频生成成功后API服务提供的视频下载接入点默认为当前服务的地址和监听端口
# 比如 http://127.0.0.1:8080/tasks/6357f542-a4e1-46a1-b4c9-bf3bd0df5285/final-1.mp4 # 比如 http://127.0.0.1:8080/tasks/6357f542-a4e1-46a1-b4c9-bf3bd0df5285/final-1.mp4
# 如果你需要使用域名对外提供服务一般会用nginx做代理则可以设置为你的域名 # 如果你需要使用域名对外提供服务一般会用nginx做代理则可以设置为你的域名
# 比如 https://xxxx.com/tasks/6357f542-a4e1-46a1-b4c9-bf3bd0df5285/final-1.mp4 # 比如 https://xxxx.com/tasks/6357f542-a4e1-46a1-b4c9-bf3bd0df5285/final-1.mp4
# endpoint="https://xxxx.com" # endpoint="https://xxxx.com"
# When the video is successfully generated, the API service provides a download endpoint for the video, defaulting to the service's current address and listening port. # When the video is successfully generated, the API service provides a download endpoint for the video, defaulting to the service's current address and listening port.
# For example, http://127.0.0.1:8080/tasks/6357f542-a4e1-46a1-b4c9-bf3bd0df5285/final-1.mp4 # For example, http://127.0.0.1:8080/tasks/6357f542-a4e1-46a1-b4c9-bf3bd0df5285/final-1.mp4
# If you need to provide the service externally using a domain name (usually done with nginx as a proxy), you can set it to your domain name. # If you need to provide the service externally using a domain name (usually done with nginx as a proxy), you can set it to your domain name.
# For example, https://xxxx.com/tasks/6357f542-a4e1-46a1-b4c9-bf3bd0df5285/final-1.mp4 # For example, https://xxxx.com/tasks/6357f542-a4e1-46a1-b4c9-bf3bd0df5285/final-1.mp4
# endpoint="https://xxxx.com" # endpoint="https://xxxx.com"
endpoint="" endpoint = ""
# Video material storage location # Video material storage location
# material_directory = "" # Indicates that video materials will be downloaded to the default folder, the default folder is ./storage/cache_videos under the current project # material_directory = "" # Indicates that video materials will be downloaded to the default folder, the default folder is ./storage/cache_videos under the current project
# material_directory = "/user/harry/videos" # Indicates that video materials will be downloaded to a specified folder # material_directory = "/user/harry/videos" # Indicates that video materials will be downloaded to a specified folder
# 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 # 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
# 视频素材存放位置 # 视频素材存放位置
# material_directory = "" #表示将视频素材下载到默认的文件夹,默认文件夹为当前项目下的 ./storage/cache_videos # material_directory = "" #表示将视频素材下载到默认的文件夹,默认文件夹为当前项目下的 ./storage/cache_videos
# material_directory = "/user/harry/videos" #表示将视频素材下载到指定的文件夹中 # material_directory = "/user/harry/videos" #表示将视频素材下载到指定的文件夹中
# material_directory = "task" #表示将视频素材下载到当前任务的文件夹中,这种方式无法共享已经下载的视频素材 # material_directory = "task" #表示将视频素材下载到当前任务的文件夹中,这种方式无法共享已经下载的视频素材
material_directory = "" material_directory = ""
# Used for state management of the task
enable_redis = false
redis_host = "localhost"
redis_port = 6379
redis_db = 0
redis_password = ""
# 文生视频时的最大并发任务数
max_concurrent_tasks = 5
# Used for state management of the task
enable_redis = false
redis_host = "localhost"
redis_port = 6379
redis_db = 0
[whisper] [whisper]
# Only effective when subtitle_provider is "whisper" # Only effective when subtitle_provider is "whisper"
# Run on GPU with FP16 # Run on GPU with FP16
# model = WhisperModel(model_size, device="cuda", compute_type="float16") # model = WhisperModel(model_size, device="cuda", compute_type="float16")
# Run on GPU with INT8 # Run on GPU with INT8
# model = WhisperModel(model_size, device="cuda", compute_type="int8_float16") # model = WhisperModel(model_size, device="cuda", compute_type="int8_float16")
# Run on CPU with INT8 # Run on CPU with INT8
# model = WhisperModel(model_size, device="cpu", compute_type="int8") # model = WhisperModel(model_size, device="cpu", compute_type="int8")
# recommended model_size: "large-v3" # recommended model_size: "large-v3"
model_size="large-v3" model_size = "large-v3"
# if you want to use GPU, set device="cuda" # if you want to use GPU, set device="cuda"
device="CPU" device = "CPU"
compute_type="int8" compute_type = "int8"
[pexels]
video_concat_mode="sequential" # "random" or "sequential" [proxy]
[pexels.proxies] ### Use a proxy to access the Pexels API
### Use a proxy to access the Pexels API ### Format: "http://<username>:<password>@<proxy>:<port>"
### Format: "http://<username>:<password>@<proxy>:<port>" ### Example: "http://user:pass@proxy:1234"
### Example: "http://user:pass@proxy:1234" ### Doc: https://requests.readthedocs.io/en/latest/user/advanced/#proxies
### Doc: https://requests.readthedocs.io/en/latest/user/advanced/#proxies
# http = "http://10.10.1.10:3128" # http = "http://10.10.1.10:3128"
# https = "http://10.10.1.10:1080" # https = "http://10.10.1.10:1080"
[azure]
# Azure Speech API Key
# Get your API key at https://portal.azure.com/#view/Microsoft_Azure_ProjectOxford/CognitiveServicesHub/~/SpeechServices
speech_key = ""
speech_region = ""
[ui]
# UI related settings
# 是否隐藏日志信息
# Whether to hide logs in the UI
hide_log = false

View File

@@ -1,5 +1,3 @@
version: "3"
x-common-volumes: &common-volumes x-common-volumes: &common-volumes
- ./:/MoneyPrinterTurbo - ./:/MoneyPrinterTurbo
@@ -8,7 +6,7 @@ services:
build: build:
context: . context: .
dockerfile: Dockerfile dockerfile: Dockerfile
container_name: "webui" container_name: "moneyprinterturbo-webui"
ports: ports:
- "8501:8501" - "8501:8501"
command: [ "streamlit", "run", "./webui/Main.py","--browser.serverAddress=127.0.0.1","--server.enableCORS=True","--browser.gatherUsageStats=False" ] command: [ "streamlit", "run", "./webui/Main.py","--browser.serverAddress=127.0.0.1","--server.enableCORS=True","--browser.gatherUsageStats=False" ]
@@ -18,7 +16,7 @@ services:
build: build:
context: . context: .
dockerfile: Dockerfile dockerfile: Dockerfile
container_name: "api" container_name: "moneyprinterturbo-api"
ports: ports:
- "8080:8080" - "8080:8080"
command: [ "python3", "main.py" ] command: [ "python3", "main.py" ]

Binary file not shown.

Before

Width:  |  Height:  |  Size: 252 KiB

After

Width:  |  Height:  |  Size: 113 KiB

BIN
docs/picwish.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 384 KiB

After

Width:  |  Height:  |  Size: 284 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 340 KiB

After

Width:  |  Height:  |  Size: 275 KiB

BIN
docs/wechat-group.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

16
main.py
View File

@@ -1,8 +1,16 @@
import uvicorn import uvicorn
from loguru import logger from loguru import logger
from app.config import config from app.config import config
if __name__ == '__main__': if __name__ == "__main__":
logger.info("start server, docs: http://127.0.0.1:" + str(config.listen_port) + "/docs") logger.info(
uvicorn.run(app="app.asgi:app", host=config.listen_host, port=config.listen_port, reload=config.reload_debug, "start server, docs: http://127.0.0.1:" + str(config.listen_port) + "/docs"
log_level="warning") )
uvicorn.run(
app="app.asgi:app",
host=config.listen_host,
port=config.listen_port,
reload=config.reload_debug,
log_level="warning",
)

2008
pdm.lock generated Normal file

File diff suppressed because it is too large Load Diff

32
pyproject.toml Normal file
View File

@@ -0,0 +1,32 @@
[project]
name = "MoneyPrinterTurbo"
version = "1.2.3"
description = "Default template for PDM package"
authors = [
{name = "yyhhyyyyyy", email = "yyhhyyyyyy8@gmail.com"},
]
dependencies = [
"moviepy==2.1.1",
"streamlit==1.40.2",
"edge-tts==6.1.19",
"fastapi==0.115.6",
"uvicorn==0.32.1",
"openai==1.56.1",
"faster-whisper==1.1.0",
"loguru==0.7.2",
"google-generativeai==0.8.3",
"dashscope==1.20.14",
"g4f==0.3.8.1",
"azure-cognitiveservices-speech==1.41.1",
"redis==5.2.0",
"python-multipart==0.0.19",
"streamlit-authenticator==0.4.1",
"pyyaml",
]
requires-python = "==3.11.*"
readme = "README.md"
license = {text = "MIT"}
[tool.pdm]
distribution = false

View File

@@ -1,19 +1,15 @@
requests~=2.31.0 moviepy==2.1.2
moviepy~=2.0.0.dev2 streamlit==1.45.0
openai~=1.13.3 edge_tts==6.1.19
faster-whisper~=1.0.1 fastapi==0.115.6
edge_tts~=6.1.10 uvicorn==0.32.1
uvicorn~=0.27.1 openai==1.56.1
fastapi~=0.110.0 faster-whisper==1.1.0
tomli~=2.0.1 loguru==0.7.3
streamlit~=1.32.0 google.generativeai==0.8.3
loguru~=0.7.2 dashscope==1.20.14
aiohttp~=3.9.3 g4f==0.5.2.2
urllib3~=2.2.1 azure-cognitiveservices-speech==1.41.1
pillow~=9.5.0 redis==5.2.0
pydantic~=2.6.3 python-multipart==0.0.19
g4f~=0.2.5.4 pyyaml
dashscope~=1.15.0
google.generativeai~=0.4.1
python-multipart~=0.0.9
redis==5.0.3

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,208 @@
import { viteBundler } from "@vuepress/bundler-vite";
import { defaultTheme } from "@vuepress/theme-default";
import { defineUserConfig } from "vuepress";
const base = "MoneyPrinterTurbo";
const isProd = process.env.NODE_ENV === "production";
export default defineUserConfig({
lang: "zh-CN",
base: `/${base}/`,
bundler: viteBundler(),
theme: defaultTheme({
repo: "harry0703/MoneyPrinterTurbo",
docsDir: "sites/docs",
colorModeSwitch: true,
locales: {
"/": {
// navbar
navbar: [
{ text: "Guide", link: "/guide/" },
// { text: "Components", link: "/components/" },
],
selectLanguageText: "Languages",
selectLanguageName: "English",
selectLanguageAriaLabel: "Select language",
// sidebar
sidebar: {
"/guide/": [
{
text: "Guide",
children: [
{ text: "Get Started", link: "/guide/README.md" },
{ text: "Video Demonstration", link: "/guide/video-demonstration.md" },
{ text: "Features", link: "/guide/features.md" },
{ text: "Speech Synthesis", link: "/guide/speech-synthesis.md" },
{ text: "Subtitle Generation", link: "/guide/subtitle-generation.md" },
{ text: "Background Music", link: "/guide/background-music.md" },
{ text: "Subtitle Font", link: "/guide/subtitle-font.md" },
],
},
{
text: "Others",
children: [
{ text: "FAQ", link: "/guide/faq.md" },
{ text: "Feedback", link: "/guide/feedback.md" },
{ text: "Reference Project", link: "/guide/reference-project.md" },
],
},
],
// "/components/": getComponentsSidebar("Components", "Advanced"),
},
// page meta
editLinkText: "Edit this page on GitHub",
},
"/zh/": {
// navbar
navbar: [
{ text: "指南", link: "/zh/guide/" },
// { text: "组件", link: "/zh/components/" },
],
selectLanguageText: "选择语言",
selectLanguageName: "简体中文",
selectLanguageAriaLabel: "选择语言",
// sidebar
sidebar: {
"/zh/guide/": [
{
text: "指南",
children: [
{ text: "快速开始", link: "/zh/guide/README.md" },
{ text: "配置要求", link: "/zh/guide/configuration-requirements.md" },
{ text: "视频演示", link: "/zh/guide/video-demonstration.md" },
{ text: "功能", link: "/zh/guide/features.md" },
{ text: "语音合成", link: "/zh/guide/speech-synthesis.md" },
{ text: "字幕生成", link: "/zh/guide/subtitle-generation.md" },
{ text: "背景音乐", link: "/zh/guide/background-music.md" },
{ text: "字幕字体", link: "/zh/guide/subtitle-font.md" },
],
},
{
text: "其他",
children: [
{ text: "常见问题", link: "/zh/guide/faq.md" },
{ text: "反馈建议", link: "/zh/guide/feedback.md" },
{ text: "参考项目", link: "/zh/guide/reference-project.md" },
{ text: "特别感谢", link: "/zh/guide/special-thanks.md" },
{ text: "感谢赞助", link: "/zh/guide/thanks-for-sponsoring" },
],
},
],
// "/zh/others/": getComponentsSidebar("组件", "高级"),
},
// page meta
editLinkText: "在 GitHub 上编辑此页",
lastUpdatedText: "上次更新",
contributorsText: "贡献者",
// custom containers
tip: "提示",
warning: "注意",
danger: "警告",
// 404 page
notFound: [
"这里什么都没有",
"我们怎么到这来了?",
"这是一个 404 页面",
"看起来我们进入了错误的链接",
],
backToHome: "返回首页",
},
},
themePlugins: {
// only enable git plugin in production mode
git: isProd,
},
}),
locales: {
"/": {
lang: "en-US",
title: "MoneyPrinterTurbo",
description: "Generate short videos with one click using AI LLM.",
},
"/zh/": {
lang: "zh-CN",
title: "MoneyPrinterTurbo",
description: "利用AI大模型一键生成高清短视频。",
},
},
head: [
[
"link",
{
rel: "icon",
type: "image/png",
sizes: "16x16",
href: `/${base}/icons/favicon-16x16.png`,
},
],
[
"link",
{
rel: "icon",
type: "image/png",
sizes: "32x32",
href: `/${base}/icons/favicon-32x32.png`,
},
],
["meta", { name: "application-name", content: "MoneyPrinterTurbo" }],
[
"meta",
{ name: "apple-mobile-web-app-title", content: "MoneyPrinterTurbo" },
],
["meta", { name: "apple-mobile-web-app-capable", content: "yes" }],
[
"meta",
{ name: "apple-mobile-web-app-status-bar-style", content: "black" },
],
[
"link",
{
rel: "apple-touch-icon",
href: `/${base}/icons/apple-touch-icon-152x152.png`,
},
],
[
"link",
{
rel: "mask-icon",
href: "/${base}/icons/safari-pinned-tab.svg",
color: "#3eaf7c",
},
],
[
"meta",
{
name: "msapplication-TileImage",
content: "/${base}/icons/msapplication-icon-144x144.png",
},
],
["meta", { name: "msapplication-TileColor", content: "#000000" }],
["meta", { name: "theme-color", content: "#3eaf7c" }],
],
});
function getGuideSidebar(groupA: string, groupB: string) {
return [
{
text: groupA,
children: ["README.md", { text: "特别感谢", link: "/zh/guide/special-thanks.md" }, "2.md"],
},
{
text: groupB,
children: ["custom-validator.md", "1.md", "2.md", "3.md"],
},
];
}
function getComponentsSidebar(groupA: string, groupB: string) {
return [
{
text: groupA,
children: ["README.md", "1.md", "2.md"],
},
{
text: groupB,
children: ["custom-components.md"],
},
];
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 252 KiB

View File

Before

Width:  |  Height:  |  Size: 172 KiB

After

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

@@ -0,0 +1,149 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
width="16.000000pt" height="16.000000pt" viewBox="0 0 16.000000 16.000000"
preserveAspectRatio="xMidYMid meet">
<metadata>
Created by potrace 1.11, written by Peter Selinger 2001-2013
</metadata>
<g transform="translate(0.000000,16.000000) scale(0.000320,-0.000320)"
fill="#000000" stroke="none">
<path d="M18 46618 c45 -75 122 -207 122 -211 0 -2 25 -45 55 -95 30 -50 55
-96 55 -102 0 -5 5 -10 10 -10 6 0 10 -4 10 -9 0 -5 73 -135 161 -288 89 -153
173 -298 187 -323 14 -25 32 -57 41 -72 88 -149 187 -324 189 -335 2 -7 8 -13
13 -13 5 0 9 -4 9 -10 0 -5 46 -89 103 -187 175 -302 490 -846 507 -876 8 -16
20 -36 25 -45 28 -46 290 -498 339 -585 13 -23 74 -129 136 -236 61 -107 123
-215 137 -240 14 -25 29 -50 33 -56 5 -5 23 -37 40 -70 18 -33 38 -67 44 -75
11 -16 21 -33 63 -109 14 -25 29 -50 33 -56 4 -5 21 -35 38 -65 55 -100 261
-455 269 -465 4 -5 14 -21 20 -35 15 -29 41 -75 103 -180 24 -41 52 -88 60
-105 9 -16 57 -100 107 -185 112 -193 362 -626 380 -660 8 -14 23 -38 33 -55
11 -16 23 -37 27 -45 4 -8 26 -46 48 -85 23 -38 53 -90 67 -115 46 -81 64
-113 178 -310 62 -107 121 -210 132 -227 37 -67 56 -99 85 -148 16 -27 32 -57
36 -65 4 -8 15 -27 25 -42 9 -15 53 -89 96 -165 44 -76 177 -307 296 -513 120
-206 268 -463 330 -570 131 -227 117 -203 200 -348 36 -62 73 -125 82 -140 10
-15 21 -34 25 -42 4 -8 20 -37 36 -65 17 -27 38 -65 48 -82 49 -85 64 -111 87
-153 13 -25 28 -49 32 -55 4 -5 78 -134 165 -285 87 -151 166 -288 176 -305
10 -16 26 -43 35 -59 9 -17 125 -217 257 -445 132 -229 253 -441 270 -471 17
-30 45 -79 64 -108 18 -29 33 -54 33 -57 0 -2 20 -37 44 -77 24 -40 123 -212
221 -383 97 -170 190 -330 205 -355 16 -25 39 -65 53 -90 13 -25 81 -144 152
-265 70 -121 137 -238 150 -260 12 -22 37 -65 55 -95 18 -30 43 -73 55 -95 12
-22 48 -85 80 -140 77 -132 163 -280 190 -330 13 -22 71 -123 130 -225 59
-102 116 -199 126 -217 10 -17 29 -50 43 -72 15 -22 26 -43 26 -45 0 -2 27
-50 60 -106 33 -56 60 -103 60 -105 0 -2 55 -98 90 -155 8 -14 182 -316 239
-414 13 -22 45 -79 72 -124 27 -46 49 -86 49 -89 0 -2 14 -24 30 -48 16 -24
30 -46 30 -49 0 -5 74 -135 100 -176 5 -8 24 -42 43 -75 50 -88 58 -101 262
-455 104 -179 199 -345 213 -370 14 -25 28 -49 32 -55 4 -5 17 -26 28 -45 10
-19 62 -109 114 -200 114 -197 133 -230 170 -295 16 -27 33 -57 38 -65 17 -28
96 -165 103 -180 4 -8 16 -28 26 -45 10 -16 77 -131 148 -255 72 -124 181
-313 243 -420 62 -107 121 -209 131 -227 35 -62 323 -560 392 -678 38 -66 83
-145 100 -175 16 -30 33 -59 37 -65 4 -5 17 -27 29 -47 34 -61 56 -100 90
-156 17 -29 31 -55 31 -57 0 -2 17 -32 39 -67 21 -35 134 -229 251 -433 117
-203 235 -407 261 -451 27 -45 49 -85 49 -88 0 -4 8 -19 19 -34 15 -21 200
-341 309 -533 10 -19 33 -58 51 -87 17 -29 31 -54 31 -56 0 -2 25 -44 55 -94
30 -50 55 -95 55 -98 0 -4 6 -15 14 -23 7 -9 27 -41 43 -71 17 -30 170 -297
342 -594 171 -296 311 -542 311 -547 0 -5 5 -9 10 -9 6 0 10 -4 10 -10 0 -5
22 -47 49 -92 27 -46 58 -99 68 -118 24 -43 81 -140 93 -160 5 -8 66 -114 135
-235 69 -121 130 -227 135 -235 12 -21 259 -447 283 -490 10 -19 28 -47 38
-62 11 -14 19 -29 19 -32 0 -3 37 -69 83 -148 99 -170 305 -526 337 -583 13
-22 31 -53 41 -70 11 -16 22 -37 26 -45 7 -14 82 -146 103 -180 14 -24 181
-311 205 -355 13 -22 46 -80 75 -130 29 -49 64 -110 78 -135 14 -25 51 -88 82
-140 31 -52 59 -102 63 -110 4 -8 18 -33 31 -55 205 -353 284 -489 309 -535
17 -30 45 -78 62 -106 18 -28 36 -60 39 -72 4 -12 12 -22 17 -22 5 0 9 -4 9
-10 0 -5 109 -197 241 -427 133 -230 250 -431 259 -448 51 -90 222 -385 280
-485 37 -63 78 -135 92 -160 14 -25 67 -117 118 -205 51 -88 101 -175 111
-193 34 -58 55 -95 149 -257 51 -88 101 -173 110 -190 9 -16 76 -131 147 -255
72 -124 140 -241 151 -260 61 -108 281 -489 355 -615 38 -66 77 -133 87 -150
35 -63 91 -161 100 -175 14 -23 99 -169 128 -220 54 -97 135 -235 142 -245 4
-5 20 -32 35 -60 26 -48 238 -416 276 -480 10 -16 26 -46 37 -65 30 -53 382
-661 403 -695 10 -16 22 -37 26 -45 4 -8 26 -48 50 -88 24 -41 43 -75 43 -77
0 -2 22 -40 50 -85 27 -45 50 -84 50 -86 0 -3 38 -69 83 -147 84 -142 302
-520 340 -587 10 -19 34 -60 52 -90 18 -30 44 -75 57 -100 14 -25 45 -79 70
-120 25 -41 56 -96 70 -121 14 -25 77 -133 138 -240 62 -107 122 -210 132
-229 25 -43 310 -535 337 -581 11 -19 26 -45 34 -59 17 -32 238 -414 266 -460
11 -19 24 -41 28 -49 3 -7 75 -133 160 -278 84 -146 153 -269 153 -274 0 -5 5
-9 10 -9 6 0 10 -4 10 -10 0 -5 82 -150 181 -322 182 -314 201 -346 240 -415
12 -21 80 -139 152 -263 71 -124 141 -245 155 -270 14 -25 28 -49 32 -55 6 -8
145 -248 220 -380 37 -66 209 -362 229 -395 11 -19 24 -42 28 -49 4 -8 67
-118 140 -243 73 -125 133 -230 133 -233 0 -2 15 -28 33 -57 19 -29 47 -78 64
-108 17 -30 53 -93 79 -139 53 -90 82 -141 157 -272 82 -142 115 -199 381
-659 142 -245 268 -463 281 -485 12 -22 71 -125 132 -230 60 -104 172 -298
248 -430 76 -132 146 -253 156 -270 11 -16 22 -36 26 -44 3 -8 30 -54 60 -103
29 -49 53 -91 53 -93 0 -3 18 -34 40 -70 22 -36 40 -67 40 -69 0 -2 37 -66 81
-142 45 -77 98 -168 119 -204 20 -36 47 -81 58 -100 12 -19 27 -47 33 -62 6
-16 15 -28 20 -28 5 0 9 -4 9 -9 0 -6 63 -118 140 -251 77 -133 140 -243 140
-245 0 -2 18 -33 41 -70 22 -37 49 -83 60 -101 10 -19 29 -51 40 -71 25 -45
109 -189 126 -218 7 -11 17 -29 22 -40 6 -11 22 -38 35 -60 14 -22 37 -62 52
-90 14 -27 35 -62 45 -77 11 -14 19 -29 19 -32 0 -3 18 -35 40 -71 22 -36 40
-67 40 -69 0 -2 19 -35 42 -72 23 -38 55 -94 72 -124 26 -47 139 -244 171
-298 6 -9 21 -36 34 -60 28 -48 37 -51 51 -19 6 12 19 36 29 52 10 17 27 46
38 65 11 19 104 181 208 360 103 179 199 345 213 370 14 25 42 74 64 109 21
34 38 65 38 67 0 2 18 33 40 69 22 36 40 67 40 69 0 3 177 310 199 346 16 26
136 234 140 244 2 5 25 44 52 88 27 44 49 81 49 84 0 2 18 34 40 70 22 36 40
67 40 69 0 2 20 36 43 77 35 58 169 289 297 513 9 17 50 86 90 155 40 69 86
150 103 180 16 30 35 62 41 70 6 8 16 24 22 35 35 64 72 129 167 293 59 100
116 199 127 220 11 20 30 53 41 72 43 72 1070 1850 1121 1940 14 25 65 113
113 195 48 83 96 166 107 185 10 19 28 50 38 68 11 18 73 124 137 235 64 111
175 303 246 427 71 124 173 299 225 390 52 91 116 202 143 248 27 45 49 85 49
89 0 4 6 14 14 22 7 9 28 43 46 76 26 47 251 436 378 655 11 19 29 51 40 70
11 19 101 176 201 348 99 172 181 317 181 323 0 5 5 9 10 9 6 0 10 5 10 11 0
6 8 23 18 37 11 15 32 52 49 82 16 30 130 228 253 440 122 212 234 405 248
430 13 25 39 70 57 100 39 65 69 117 130 225 25 44 50 87 55 95 12 19 78 134
220 380 61 107 129 224 150 260 161 277 222 382 246 425 15 28 47 83 71 123
24 41 43 78 43 83 0 5 4 9 8 9 4 0 13 12 19 28 7 15 23 45 36 67 66 110 277
478 277 483 0 3 6 13 14 21 7 9 27 41 43 71 17 30 45 80 63 110 34 57 375 649
394 685 6 11 16 27 22 35 6 8 26 42 44 75 18 33 41 74 51 90 10 17 24 41 32
55 54 97 72 128 88 152 11 14 19 28 19 30 0 3 79 141 175 308 96 167 175 305
175 308 0 3 6 13 14 21 7 9 26 39 41 66 33 60 276 483 338 587 24 40 46 80 50
88 4 8 13 24 20 35 14 23 95 163 125 215 11 19 52 91 92 160 40 69 80 139 90
155 9 17 103 179 207 360 105 182 200 346 211 365 103 181 463 802 489 845 7
11 15 27 19 35 4 8 29 51 55 95 64 110 828 1433 848 1470 9 17 24 41 33 55 9
14 29 48 45 77 15 28 52 93 82 145 30 51 62 107 71 123 17 30 231 398 400 690
51 88 103 179 115 202 12 23 26 48 32 55 6 7 24 38 40 68 17 30 61 107 98 170
37 63 84 144 103 180 19 36 41 72 48 81 8 8 14 18 14 21 0 4 27 51 59 106 32
55 72 124 89 154 16 29 71 125 122 213 51 88 104 180 118 205 13 25 28 50 32
55 4 6 17 26 28 45 11 19 45 80 77 135 31 55 66 116 77 135 11 19 88 152 171
295 401 694 620 1072 650 1125 11 19 87 152 170 295 83 143 158 273 166 288 9
16 21 36 26 45 6 9 31 52 55 96 25 43 54 94 66 115 11 20 95 164 186 321 91
157 173 299 182 315 9 17 26 46 37 65 12 19 66 114 121 210 56 96 108 186 117
200 8 14 24 40 34 59 24 45 383 664 412 713 5 9 17 29 26 45 15 28 120 210
241 419 36 61 68 117 72 125 4 8 12 23 19 34 35 57 245 420 262 453 11 20 35
61 53 90 17 29 32 54 32 56 0 3 28 51 62 108 33 57 70 119 80 138 10 19 23 42
28 50 5 8 32 53 59 100 27 47 149 258 271 470 122 212 234 405 248 430 30 53
62 108 80 135 6 11 15 27 19 35 4 8 85 150 181 315 96 165 187 323 202 350 31
56 116 202 130 225 5 8 25 42 43 75 19 33 92 159 162 280 149 257 157 271 202
350 19 33 38 67 43 75 9 14 228 392 275 475 12 22 55 96 95 165 40 69 80 139
90 155 24 42 202 350 221 383 9 15 27 47 41 72 14 25 75 131 136 236 61 106
121 210 134 232 99 172 271 470 279 482 5 8 23 40 40 70 18 30 81 141 142 245
60 105 121 210 135 235 14 25 71 124 127 220 56 96 143 247 194 335 51 88 96
167 102 175 14 24 180 311 204 355 23 43 340 590 356 615 5 8 50 87 101 175
171 301 517 898 582 1008 25 43 46 81 46 83 0 2 12 23 27 47 14 23 40 67 56
97 16 30 35 62 42 70 7 8 15 22 18 30 4 8 20 38 37 65 16 28 33 57 37 65 6 12
111 196 143 250 5 8 55 95 112 193 57 98 113 195 126 215 12 20 27 46 32 57 6
11 14 27 20 35 5 8 76 130 156 270 80 140 165 287 187 325 23 39 52 90 66 115
13 25 30 52 37 61 8 8 14 18 14 21 0 4 41 77 92 165 50 87 175 302 276 478
101 176 208 360 236 408 28 49 67 117 86 152 19 35 41 70 48 77 6 6 12 15 12
19 0 7 124 224 167 291 12 21 23 40 23 42 0 2 21 40 46 83 26 43 55 92 64 109
54 95 327 568 354 614 19 30 45 75 59 100 71 128 82 145 89 148 4 2 8 8 8 13
0 5 42 82 94 172 311 538 496 858 518 897 14 25 40 70 58 100 18 30 42 71 53
90 10 19 79 139 152 265 73 127 142 246 153 265 10 19 43 76 72 125 29 50 63
108 75 130 65 116 80 140 87 143 4 2 8 8 8 12 0 8 114 212 140 250 6 8 14 24
20 35 5 11 54 97 108 190 l100 170 -9611 3 c-5286 1 -9614 -1 -9618 -5 -5 -6
-419 -719 -619 -1068 -89 -155 -267 -463 -323 -560 -38 -66 -81 -140 -95 -165
-31 -56 -263 -457 -526 -910 -110 -190 -224 -388 -254 -440 -29 -52 -61 -109
-71 -125 -23 -39 -243 -420 -268 -465 -11 -19 -204 -352 -428 -740 -224 -388
-477 -826 -563 -975 -85 -148 -185 -322 -222 -385 -37 -63 -120 -207 -185
-320 -65 -113 -177 -306 -248 -430 -72 -124 -172 -297 -222 -385 -51 -88 -142
-245 -202 -350 -131 -226 -247 -427 -408 -705 -65 -113 -249 -432 -410 -710
-160 -278 -388 -673 -506 -877 -118 -205 -216 -373 -219 -373 -3 0 -52 82
-109 183 -58 100 -144 250 -192 332 -95 164 -402 696 -647 1120 -85 149 -228
396 -317 550 -212 365 -982 1700 -1008 1745 -10 19 -43 76 -72 125 -29 50 -64
110 -77 135 -14 25 -63 110 -110 190 -47 80 -96 165 -110 190 -14 25 -99 171
-188 325 -89 154 -174 300 -188 325 -13 25 -64 113 -112 195 -48 83 -140 242
-205 355 -65 113 -183 317 -263 454 -79 137 -152 264 -163 282 -50 89 -335
583 -354 614 -12 19 -34 58 -50 85 -15 28 -129 226 -253 440 -124 215 -235
408 -247 430 -12 22 -69 121 -127 220 -58 99 -226 389 -373 645 -148 256 -324
561 -392 678 -67 117 -134 232 -147 255 -13 23 -33 59 -46 80 l-22 37 -9615 0
-9615 0 20 -32z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

View File

@@ -0,0 +1,21 @@
{
"name": "VuePress",
"short_name": "VuePress",
"description": "Vue-powered Static Site Generator",
"start_url": "/index.html",
"display": "standalone",
"background_color": "#fff",
"theme_color": "#3eaf7c",
"icons": [
{
"src": "/icons/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/icons/android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 294 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 256 KiB

View File

Before

Width:  |  Height:  |  Size: 100 KiB

After

Width:  |  Height:  |  Size: 100 KiB

View File

@@ -0,0 +1,941 @@
Name: af-ZA-AdriNeural
Gender: Female
Name: af-ZA-WillemNeural
Gender: Male
Name: am-ET-AmehaNeural
Gender: Male
Name: am-ET-MekdesNeural
Gender: Female
Name: ar-AE-FatimaNeural
Gender: Female
Name: ar-AE-HamdanNeural
Gender: Male
Name: ar-BH-AliNeural
Gender: Male
Name: ar-BH-LailaNeural
Gender: Female
Name: ar-DZ-AminaNeural
Gender: Female
Name: ar-DZ-IsmaelNeural
Gender: Male
Name: ar-EG-SalmaNeural
Gender: Female
Name: ar-EG-ShakirNeural
Gender: Male
Name: ar-IQ-BasselNeural
Gender: Male
Name: ar-IQ-RanaNeural
Gender: Female
Name: ar-JO-SanaNeural
Gender: Female
Name: ar-JO-TaimNeural
Gender: Male
Name: ar-KW-FahedNeural
Gender: Male
Name: ar-KW-NouraNeural
Gender: Female
Name: ar-LB-LaylaNeural
Gender: Female
Name: ar-LB-RamiNeural
Gender: Male
Name: ar-LY-ImanNeural
Gender: Female
Name: ar-LY-OmarNeural
Gender: Male
Name: ar-MA-JamalNeural
Gender: Male
Name: ar-MA-MounaNeural
Gender: Female
Name: ar-OM-AbdullahNeural
Gender: Male
Name: ar-OM-AyshaNeural
Gender: Female
Name: ar-QA-AmalNeural
Gender: Female
Name: ar-QA-MoazNeural
Gender: Male
Name: ar-SA-HamedNeural
Gender: Male
Name: ar-SA-ZariyahNeural
Gender: Female
Name: ar-SY-AmanyNeural
Gender: Female
Name: ar-SY-LaithNeural
Gender: Male
Name: ar-TN-HediNeural
Gender: Male
Name: ar-TN-ReemNeural
Gender: Female
Name: ar-YE-MaryamNeural
Gender: Female
Name: ar-YE-SalehNeural
Gender: Male
Name: az-AZ-BabekNeural
Gender: Male
Name: az-AZ-BanuNeural
Gender: Female
Name: bg-BG-BorislavNeural
Gender: Male
Name: bg-BG-KalinaNeural
Gender: Female
Name: bn-BD-NabanitaNeural
Gender: Female
Name: bn-BD-PradeepNeural
Gender: Male
Name: bn-IN-BashkarNeural
Gender: Male
Name: bn-IN-TanishaaNeural
Gender: Female
Name: bs-BA-GoranNeural
Gender: Male
Name: bs-BA-VesnaNeural
Gender: Female
Name: ca-ES-EnricNeural
Gender: Male
Name: ca-ES-JoanaNeural
Gender: Female
Name: cs-CZ-AntoninNeural
Gender: Male
Name: cs-CZ-VlastaNeural
Gender: Female
Name: cy-GB-AledNeural
Gender: Male
Name: cy-GB-NiaNeural
Gender: Female
Name: da-DK-ChristelNeural
Gender: Female
Name: da-DK-JeppeNeural
Gender: Male
Name: de-AT-IngridNeural
Gender: Female
Name: de-AT-JonasNeural
Gender: Male
Name: de-CH-JanNeural
Gender: Male
Name: de-CH-LeniNeural
Gender: Female
Name: de-DE-AmalaNeural
Gender: Female
Name: de-DE-ConradNeural
Gender: Male
Name: de-DE-FlorianMultilingualNeural
Gender: Male
Name: de-DE-KatjaNeural
Gender: Female
Name: de-DE-KillianNeural
Gender: Male
Name: de-DE-SeraphinaMultilingualNeural
Gender: Female
Name: el-GR-AthinaNeural
Gender: Female
Name: el-GR-NestorasNeural
Gender: Male
Name: en-AU-NatashaNeural
Gender: Female
Name: en-AU-WilliamNeural
Gender: Male
Name: en-CA-ClaraNeural
Gender: Female
Name: en-CA-LiamNeural
Gender: Male
Name: en-GB-LibbyNeural
Gender: Female
Name: en-GB-MaisieNeural
Gender: Female
Name: en-GB-RyanNeural
Gender: Male
Name: en-GB-SoniaNeural
Gender: Female
Name: en-GB-ThomasNeural
Gender: Male
Name: en-HK-SamNeural
Gender: Male
Name: en-HK-YanNeural
Gender: Female
Name: en-IE-ConnorNeural
Gender: Male
Name: en-IE-EmilyNeural
Gender: Female
Name: en-IN-NeerjaExpressiveNeural
Gender: Female
Name: en-IN-NeerjaNeural
Gender: Female
Name: en-IN-PrabhatNeural
Gender: Male
Name: en-KE-AsiliaNeural
Gender: Female
Name: en-KE-ChilembaNeural
Gender: Male
Name: en-NG-AbeoNeural
Gender: Male
Name: en-NG-EzinneNeural
Gender: Female
Name: en-NZ-MitchellNeural
Gender: Male
Name: en-NZ-MollyNeural
Gender: Female
Name: en-PH-JamesNeural
Gender: Male
Name: en-PH-RosaNeural
Gender: Female
Name: en-SG-LunaNeural
Gender: Female
Name: en-SG-WayneNeural
Gender: Male
Name: en-TZ-ElimuNeural
Gender: Male
Name: en-TZ-ImaniNeural
Gender: Female
Name: en-US-AnaNeural
Gender: Female
Name: en-US-AndrewNeural
Gender: Male
Name: en-US-AriaNeural
Gender: Female
Name: en-US-AvaNeural
Gender: Female
Name: en-US-BrianNeural
Gender: Male
Name: en-US-ChristopherNeural
Gender: Male
Name: en-US-EmmaNeural
Gender: Female
Name: en-US-EricNeural
Gender: Male
Name: en-US-GuyNeural
Gender: Male
Name: en-US-JennyNeural
Gender: Female
Name: en-US-MichelleNeural
Gender: Female
Name: en-US-RogerNeural
Gender: Male
Name: en-US-SteffanNeural
Gender: Male
Name: en-ZA-LeahNeural
Gender: Female
Name: en-ZA-LukeNeural
Gender: Male
Name: es-AR-ElenaNeural
Gender: Female
Name: es-AR-TomasNeural
Gender: Male
Name: es-BO-MarceloNeural
Gender: Male
Name: es-BO-SofiaNeural
Gender: Female
Name: es-CL-CatalinaNeural
Gender: Female
Name: es-CL-LorenzoNeural
Gender: Male
Name: es-CO-GonzaloNeural
Gender: Male
Name: es-CO-SalomeNeural
Gender: Female
Name: es-CR-JuanNeural
Gender: Male
Name: es-CR-MariaNeural
Gender: Female
Name: es-CU-BelkysNeural
Gender: Female
Name: es-CU-ManuelNeural
Gender: Male
Name: es-DO-EmilioNeural
Gender: Male
Name: es-DO-RamonaNeural
Gender: Female
Name: es-EC-AndreaNeural
Gender: Female
Name: es-EC-LuisNeural
Gender: Male
Name: es-ES-AlvaroNeural
Gender: Male
Name: es-ES-ElviraNeural
Gender: Female
Name: es-ES-XimenaNeural
Gender: Female
Name: es-GQ-JavierNeural
Gender: Male
Name: es-GQ-TeresaNeural
Gender: Female
Name: es-GT-AndresNeural
Gender: Male
Name: es-GT-MartaNeural
Gender: Female
Name: es-HN-CarlosNeural
Gender: Male
Name: es-HN-KarlaNeural
Gender: Female
Name: es-MX-DaliaNeural
Gender: Female
Name: es-MX-JorgeNeural
Gender: Male
Name: es-NI-FedericoNeural
Gender: Male
Name: es-NI-YolandaNeural
Gender: Female
Name: es-PA-MargaritaNeural
Gender: Female
Name: es-PA-RobertoNeural
Gender: Male
Name: es-PE-AlexNeural
Gender: Male
Name: es-PE-CamilaNeural
Gender: Female
Name: es-PR-KarinaNeural
Gender: Female
Name: es-PR-VictorNeural
Gender: Male
Name: es-PY-MarioNeural
Gender: Male
Name: es-PY-TaniaNeural
Gender: Female
Name: es-SV-LorenaNeural
Gender: Female
Name: es-SV-RodrigoNeural
Gender: Male
Name: es-US-AlonsoNeural
Gender: Male
Name: es-US-PalomaNeural
Gender: Female
Name: es-UY-MateoNeural
Gender: Male
Name: es-UY-ValentinaNeural
Gender: Female
Name: es-VE-PaolaNeural
Gender: Female
Name: es-VE-SebastianNeural
Gender: Male
Name: et-EE-AnuNeural
Gender: Female
Name: et-EE-KertNeural
Gender: Male
Name: fa-IR-DilaraNeural
Gender: Female
Name: fa-IR-FaridNeural
Gender: Male
Name: fi-FI-HarriNeural
Gender: Male
Name: fi-FI-NooraNeural
Gender: Female
Name: fil-PH-AngeloNeural
Gender: Male
Name: fil-PH-BlessicaNeural
Gender: Female
Name: fr-BE-CharlineNeural
Gender: Female
Name: fr-BE-GerardNeural
Gender: Male
Name: fr-CA-AntoineNeural
Gender: Male
Name: fr-CA-JeanNeural
Gender: Male
Name: fr-CA-SylvieNeural
Gender: Female
Name: fr-CA-ThierryNeural
Gender: Male
Name: fr-CH-ArianeNeural
Gender: Female
Name: fr-CH-FabriceNeural
Gender: Male
Name: fr-FR-DeniseNeural
Gender: Female
Name: fr-FR-EloiseNeural
Gender: Female
Name: fr-FR-HenriNeural
Gender: Male
Name: fr-FR-RemyMultilingualNeural
Gender: Male
Name: fr-FR-VivienneMultilingualNeural
Gender: Female
Name: ga-IE-ColmNeural
Gender: Male
Name: ga-IE-OrlaNeural
Gender: Female
Name: gl-ES-RoiNeural
Gender: Male
Name: gl-ES-SabelaNeural
Gender: Female
Name: gu-IN-DhwaniNeural
Gender: Female
Name: gu-IN-NiranjanNeural
Gender: Male
Name: he-IL-AvriNeural
Gender: Male
Name: he-IL-HilaNeural
Gender: Female
Name: hi-IN-MadhurNeural
Gender: Male
Name: hi-IN-SwaraNeural
Gender: Female
Name: hr-HR-GabrijelaNeural
Gender: Female
Name: hr-HR-SreckoNeural
Gender: Male
Name: hu-HU-NoemiNeural
Gender: Female
Name: hu-HU-TamasNeural
Gender: Male
Name: id-ID-ArdiNeural
Gender: Male
Name: id-ID-GadisNeural
Gender: Female
Name: is-IS-GudrunNeural
Gender: Female
Name: is-IS-GunnarNeural
Gender: Male
Name: it-IT-DiegoNeural
Gender: Male
Name: it-IT-ElsaNeural
Gender: Female
Name: it-IT-GiuseppeNeural
Gender: Male
Name: it-IT-IsabellaNeural
Gender: Female
Name: ja-JP-KeitaNeural
Gender: Male
Name: ja-JP-NanamiNeural
Gender: Female
Name: jv-ID-DimasNeural
Gender: Male
Name: jv-ID-SitiNeural
Gender: Female
Name: ka-GE-EkaNeural
Gender: Female
Name: ka-GE-GiorgiNeural
Gender: Male
Name: kk-KZ-AigulNeural
Gender: Female
Name: kk-KZ-DauletNeural
Gender: Male
Name: km-KH-PisethNeural
Gender: Male
Name: km-KH-SreymomNeural
Gender: Female
Name: kn-IN-GaganNeural
Gender: Male
Name: kn-IN-SapnaNeural
Gender: Female
Name: ko-KR-HyunsuNeural
Gender: Male
Name: ko-KR-InJoonNeural
Gender: Male
Name: ko-KR-SunHiNeural
Gender: Female
Name: lo-LA-ChanthavongNeural
Gender: Male
Name: lo-LA-KeomanyNeural
Gender: Female
Name: lt-LT-LeonasNeural
Gender: Male
Name: lt-LT-OnaNeural
Gender: Female
Name: lv-LV-EveritaNeural
Gender: Female
Name: lv-LV-NilsNeural
Gender: Male
Name: mk-MK-AleksandarNeural
Gender: Male
Name: mk-MK-MarijaNeural
Gender: Female
Name: ml-IN-MidhunNeural
Gender: Male
Name: ml-IN-SobhanaNeural
Gender: Female
Name: mn-MN-BataaNeural
Gender: Male
Name: mn-MN-YesuiNeural
Gender: Female
Name: mr-IN-AarohiNeural
Gender: Female
Name: mr-IN-ManoharNeural
Gender: Male
Name: ms-MY-OsmanNeural
Gender: Male
Name: ms-MY-YasminNeural
Gender: Female
Name: mt-MT-GraceNeural
Gender: Female
Name: mt-MT-JosephNeural
Gender: Male
Name: my-MM-NilarNeural
Gender: Female
Name: my-MM-ThihaNeural
Gender: Male
Name: nb-NO-FinnNeural
Gender: Male
Name: nb-NO-PernilleNeural
Gender: Female
Name: ne-NP-HemkalaNeural
Gender: Female
Name: ne-NP-SagarNeural
Gender: Male
Name: nl-BE-ArnaudNeural
Gender: Male
Name: nl-BE-DenaNeural
Gender: Female
Name: nl-NL-ColetteNeural
Gender: Female
Name: nl-NL-FennaNeural
Gender: Female
Name: nl-NL-MaartenNeural
Gender: Male
Name: pl-PL-MarekNeural
Gender: Male
Name: pl-PL-ZofiaNeural
Gender: Female
Name: ps-AF-GulNawazNeural
Gender: Male
Name: ps-AF-LatifaNeural
Gender: Female
Name: pt-BR-AntonioNeural
Gender: Male
Name: pt-BR-FranciscaNeural
Gender: Female
Name: pt-BR-ThalitaNeural
Gender: Female
Name: pt-PT-DuarteNeural
Gender: Male
Name: pt-PT-RaquelNeural
Gender: Female
Name: ro-RO-AlinaNeural
Gender: Female
Name: ro-RO-EmilNeural
Gender: Male
Name: ru-RU-DmitryNeural
Gender: Male
Name: ru-RU-SvetlanaNeural
Gender: Female
Name: si-LK-SameeraNeural
Gender: Male
Name: si-LK-ThiliniNeural
Gender: Female
Name: sk-SK-LukasNeural
Gender: Male
Name: sk-SK-ViktoriaNeural
Gender: Female
Name: sl-SI-PetraNeural
Gender: Female
Name: sl-SI-RokNeural
Gender: Male
Name: so-SO-MuuseNeural
Gender: Male
Name: so-SO-UbaxNeural
Gender: Female
Name: sq-AL-AnilaNeural
Gender: Female
Name: sq-AL-IlirNeural
Gender: Male
Name: sr-RS-NicholasNeural
Gender: Male
Name: sr-RS-SophieNeural
Gender: Female
Name: su-ID-JajangNeural
Gender: Male
Name: su-ID-TutiNeural
Gender: Female
Name: sv-SE-MattiasNeural
Gender: Male
Name: sv-SE-SofieNeural
Gender: Female
Name: sw-KE-RafikiNeural
Gender: Male
Name: sw-KE-ZuriNeural
Gender: Female
Name: sw-TZ-DaudiNeural
Gender: Male
Name: sw-TZ-RehemaNeural
Gender: Female
Name: ta-IN-PallaviNeural
Gender: Female
Name: ta-IN-ValluvarNeural
Gender: Male
Name: ta-LK-KumarNeural
Gender: Male
Name: ta-LK-SaranyaNeural
Gender: Female
Name: ta-MY-KaniNeural
Gender: Female
Name: ta-MY-SuryaNeural
Gender: Male
Name: ta-SG-AnbuNeural
Gender: Male
Name: ta-SG-VenbaNeural
Gender: Female
Name: te-IN-MohanNeural
Gender: Male
Name: te-IN-ShrutiNeural
Gender: Female
Name: th-TH-NiwatNeural
Gender: Male
Name: th-TH-PremwadeeNeural
Gender: Female
Name: tr-TR-AhmetNeural
Gender: Male
Name: tr-TR-EmelNeural
Gender: Female
Name: uk-UA-OstapNeural
Gender: Male
Name: uk-UA-PolinaNeural
Gender: Female
Name: ur-IN-GulNeural
Gender: Female
Name: ur-IN-SalmanNeural
Gender: Male
Name: ur-PK-AsadNeural
Gender: Male
Name: ur-PK-UzmaNeural
Gender: Female
Name: uz-UZ-MadinaNeural
Gender: Female
Name: uz-UZ-SardorNeural
Gender: Male
Name: vi-VN-HoaiMyNeural
Gender: Female
Name: vi-VN-NamMinhNeural
Gender: Male
Name: zh-CN-XiaoxiaoNeural
Gender: Female
Name: zh-CN-XiaoyiNeural
Gender: Female
Name: zh-CN-YunjianNeural
Gender: Male
Name: zh-CN-YunxiNeural
Gender: Male
Name: zh-CN-YunxiaNeural
Gender: Male
Name: zh-CN-YunyangNeural
Gender: Male
Name: zh-CN-liaoning-XiaobeiNeural
Gender: Female
Name: zh-CN-shaanxi-XiaoniNeural
Gender: Female
Name: zh-HK-HiuGaaiNeural
Gender: Female
Name: zh-HK-HiuMaanNeural
Gender: Female
Name: zh-HK-WanLungNeural
Gender: Male
Name: zh-TW-HsiaoChenNeural
Gender: Female
Name: zh-TW-HsiaoYuNeural
Gender: Female
Name: zh-TW-YunJheNeural
Gender: Male
Name: zu-ZA-ThandoNeural
Gender: Female
Name: zu-ZA-ThembaNeural
Gender: Male

Binary file not shown.

After

Width:  |  Height:  |  Size: 340 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 384 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 340 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

16
sites/docs/README.md Normal file
View File

@@ -0,0 +1,16 @@
---
home: true
heroImage: /hero.png
actions:
- text: Get Started →
link: /guide/
type: primary
features:
- title: Multilingual
details: Supports video scripts in both Chinese and English; offers multiple voice synthesis options.
- title: Maintainability
details: Complete MVC architecture with clear code structure, easy to maintain, supports both API and Web interface.
- title: Multi-Model Support
details: Supports integration with multiple models including OpenAI, moonshot, Azure, gpt4free, one-api, Tongyi Qianwen, Google Gemini, Ollama, and others.
footer: MIT Licensed | Copyright © 2024-present MoneyPrinterTurbo
---

134
sites/docs/guide/README.md Normal file
View File

@@ -0,0 +1,134 @@
## Installation & Deployment 📥
Simply provide a <b>topic</b> or <b>keyword</b> for a video, and it will automatically generate the video copy, video
materials, video subtitles, and video background music before synthesizing a high-definition short video.
### WebUI
![](/webui-en.jpg)
### API Interface
![](/api.jpg)
- Try to avoid using **Chinese paths** to prevent unpredictable issues
- Ensure your **network** is stable, meaning you can access foreign websites normally
#### ① Clone the Project
```shell
git clone https://github.com/harry0703/MoneyPrinterTurbo.git
```
#### ② Modify the Configuration File
- Copy the `config.example.toml` file and rename it to `config.toml`
- Follow the instructions in the `config.toml` file to configure `pexels_api_keys` and `llm_provider`, and according to
the llm_provider's service provider, set up the corresponding API Key
#### ③ Configure Large Language Models (LLM)
- To use `GPT-4.0` or `GPT-3.5`, you need an `API Key` from `OpenAI`. If you don't have one, you can set `llm_provider`
to `g4f` (a free-to-use GPT library https://github.com/xtekky/gpt4free)
### Docker Deployment 🐳
#### ① Launch the Docker Container
If you haven't installed Docker, please install it first https://www.docker.com/products/docker-desktop/
If you are using a Windows system, please refer to Microsoft's documentation:
1. https://learn.microsoft.com/en-us/windows/wsl/install
2. https://learn.microsoft.com/en-us/windows/wsl/tutorials/wsl-containers
```shell
cd MoneyPrinterTurbo
docker-compose up
```
#### ② Access the Web Interface
Open your browser and visit http://0.0.0.0:8501
#### ③ Access the API Interface
Open your browser and visit http://0.0.0.0:8080/docs Or http://0.0.0.0:8080/redoc
### Manual Deployment 📦
#### ① Create a Python Virtual Environment
It is recommended to create a Python virtual environment
using [conda](https://conda.io/projects/conda/en/latest/user-guide/install/index.html)
```shell
git clone https://github.com/harry0703/MoneyPrinterTurbo.git
cd MoneyPrinterTurbo
conda create -n MoneyPrinterTurbo python=3.10
conda activate MoneyPrinterTurbo
pip install -r requirements.txt
```
#### ② Install ImageMagick
###### Windows:
- Download https://imagemagick.org/archive/binaries/ImageMagick-7.1.1-29-Q16-x64-static.exe
- Install the downloaded ImageMagick, **do not change the installation path**
- Modify the `config.toml` configuration file, set `imagemagick_path` to your actual installation path (if you didn't
change the path during installation, just uncomment it)
###### MacOS:
```shell
brew install imagemagick
```
###### Ubuntu
```shell
sudo apt-get install imagemagick
```
###### CentOS
```shell
sudo yum install ImageMagick
```
#### ③ Launch the Web Interface 🌐
Note that you need to execute the following commands in the `root directory` of the MoneyPrinterTurbo project
###### Windows
```bat
conda activate MoneyPrinterTurbo
webui.bat
```
###### MacOS or Linux
```shell
conda activate MoneyPrinterTurbo
sh webui.sh
```
After launching, the browser will open automatically
#### ④ Launch the API Service 🚀
```shell
python main.py
```
After launching, you can view the `API documentation` at http://127.0.0.1:8080/docs and directly test the interface
online for a quick experience.
## License 📝
Click to view the [`LICENSE`](LICENSE) file
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=harry0703/MoneyPrinterTurbo&type=Date)](https://star-history.com/#harry0703/MoneyPrinterTurbo&Date)

View File

@@ -0,0 +1,5 @@
## Background Music 🎵
Background music for videos is located in the project's `resource/songs` directory.
> The current project includes some default music from YouTube videos. If there are copyright issues, please delete
> them.

70
sites/docs/guide/faq.md Normal file
View File

@@ -0,0 +1,70 @@
## Common Questions 🤔
### ❓How to Use the Free OpenAI GPT-3.5 Model?
[OpenAI has announced that ChatGPT with 3.5 is now free](https://openai.com/blog/start-using-chatgpt-instantly), and
developers have wrapped it into an API for direct usage.
**Ensure you have Docker installed and running**. Execute the following command to start the Docker service:
```shell
docker run -p 3040:3040 missuo/freegpt35
```
Once successfully started, modify the `config.toml` configuration as follows:
- Set `llm_provider` to `openai`
- Fill in `openai_api_key` with any value, for example, '123456'
- Change `openai_base_url` to `http://localhost:3040/v1/`
- Set `openai_model_name` to `gpt-3.5-turbo`
### ❓RuntimeError: No ffmpeg exe could be found
Normally, ffmpeg will be automatically downloaded and detected.
However, if your environment has issues preventing automatic downloads, you may encounter the following error:
```
RuntimeError: No ffmpeg exe could be found.
Install ffmpeg on your system, or set the IMAGEIO_FFMPEG_EXE environment variable.
```
In this case, you can download ffmpeg from https://www.gyan.dev/ffmpeg/builds/, unzip it, and set `ffmpeg_path` to your
actual installation path.
```toml
[app]
# Please set according to your actual path, note that Windows path separators are \\
ffmpeg_path = "C:\\Users\\harry\\Downloads\\ffmpeg.exe"
```
### ❓Error generating audio or downloading videos
[issue 56](https://github.com/harry0703/MoneyPrinterTurbo/issues/56)
```
failed to generate audio, maybe the network is not available.
if you are in China, please use a VPN.
```
[issue 44](https://github.com/harry0703/MoneyPrinterTurbo/issues/44)
```
failed to download videos, maybe the network is not available.
if you are in China, please use a VPN.
```
This is likely due to network issues preventing access to foreign services. Please use a VPN to resolve this.
### ❓ImageMagick is not installed on your computer
[issue 33](https://github.com/harry0703/MoneyPrinterTurbo/issues/33)
1. Follow the `example configuration` provided `download address` to
install https://imagemagick.org/archive/binaries/ImageMagick-7.1.1-30-Q16-x64-static.exe, using the static library
2. Do not install in a path with Chinese characters to avoid unpredictable issues
[issue 54](https://github.com/harry0703/MoneyPrinterTurbo/issues/54#issuecomment-2017842022)
For Linux systems, you can manually install it, refer to https://cn.linux-console.net/?p=16978
Thanks to [@wangwenqiao666](https://github.com/wangwenqiao666) for their research and exploration

View File

@@ -0,0 +1,34 @@
## Features 🎯
- [x] Complete **MVC architecture**, **clearly structured** code, easy to maintain, supports both `API`
and `Web interface`
- [x] Supports **AI-generated** video copy, as well as **customized copy**
- [x] Supports various **high-definition video** sizes
- [x] Portrait 9:16, `1080x1920`
- [x] Landscape 16:9, `1920x1080`
- [x] Supports **batch video generation**, allowing the creation of multiple videos at once, then selecting the most
satisfactory one
- [x] Supports setting the **duration of video clips**, facilitating adjustments to material switching frequency
- [x] Supports video copy in both **Chinese** and **English**
- [x] Supports **multiple voice** synthesis
- [x] Supports **subtitle generation**, with adjustable `font`, `position`, `color`, `size`, and also
supports `subtitle outlining`
- [x] Supports **background music**, either random or specified music files, with adjustable `background music volume`
- [x] Video material sources are **high-definition** and **royalty-free**
- [x] Supports integration with various models such as **OpenAI**, **moonshot**, **Azure**, **gpt4free**, **one-api**,
**qianwen**, **Google Gemini**, **Ollama** and more
❓[How to Use the Free OpenAI GPT-3.5 Model?](https://github.com/harry0703/MoneyPrinterTurbo/blob/main/README-en.md#common-questions-)
### Future Plans 📅
- [ ] Introduce support for GPT-SoVITS dubbing
- [ ] Enhance voice synthesis with large models for a more natural and emotionally resonant voice output
- [ ] Incorporate video transition effects to ensure a smoother viewing experience
- [ ] Improve the relevance of video content
- [ ] Add options for video length: short, medium, long
- [ ] Package the application into a one-click launch bundle for Windows and macOS for ease of use
- [ ] Enable the use of custom materials
- [ ] Offer voiceover and background music options with real-time preview
- [ ] Support a wider range of voice synthesis providers, such as OpenAI TTS, Azure TTS
- [ ] Automate the upload process to the YouTube platform

View File

@@ -0,0 +1,4 @@
## Feedback & Suggestions 📢
- You can submit an [issue](https://github.com/harry0703/MoneyPrinterTurbo/issues) or
a [pull request](https://github.com/harry0703/MoneyPrinterTurbo/pulls).

View File

@@ -0,0 +1,4 @@
## Reference Projects 📚
This project is based on https://github.com/FujiwaraChoki/MoneyPrinter and has been refactored with a lot of
optimizations and added functionalities. Thanks to the original author for their spirit of open source.

View File

@@ -0,0 +1,3 @@
## Voice Synthesis 🗣
A list of all supported voices can be viewed here: [Voice List](/voice-list.txt)

View File

@@ -0,0 +1,4 @@
## Subtitle Fonts 🅰
Fonts for rendering video subtitles are located in the project's `resource/fonts` directory, and you can also add your
own fonts.

View File

@@ -0,0 +1,15 @@
## Subtitle Generation 📜
Currently, there are 2 ways to generate subtitles:
- edge: Faster generation speed, better performance, no specific requirements for computer configuration, but the
quality may be unstable
- whisper: Slower generation speed, poorer performance, specific requirements for computer configuration, but more
reliable quality
You can switch between them by modifying the `subtitle_provider` in the `config.toml` configuration file
It is recommended to use `edge` mode, and switch to `whisper` mode if the quality of the subtitles generated is not
satisfactory.
> If left blank, it means no subtitles will be generated.

View File

@@ -0,0 +1,35 @@
## Video Demos 📺
### Portrait 9:16
<table>
<thead>
<tr>
<th align="center"><g-emoji class="g-emoji" alias="arrow_forward">▶️</g-emoji> How to Add Fun to Your Life </th>
<th align="center"><g-emoji class="g-emoji" alias="arrow_forward">▶️</g-emoji> What is the Meaning of Life</th>
</tr>
</thead>
<tbody>
<tr>
<td align="center"><video src="https://github.com/harry0703/MoneyPrinterTurbo/assets/4928832/a84d33d5-27a2-4aba-8fd0-9fb2bd91c6a6"></video></td>
<td align="center"><video src="https://github.com/harry0703/MoneyPrinterTurbo/assets/4928832/112c9564-d52b-4472-99ad-970b75f66476"></video></td>
</tr>
</tbody>
</table>
### Landscape 16:9
<table>
<thead>
<tr>
<th align="center"><g-emoji class="g-emoji" alias="arrow_forward">▶️</g-emoji> What is the Meaning of Life</th>
<th align="center"><g-emoji class="g-emoji" alias="arrow_forward">▶️</g-emoji> Why Exercise</th>
</tr>
</thead>
<tbody>
<tr>
<td align="center"><video src="https://github.com/harry0703/MoneyPrinterTurbo/assets/4928832/346ebb15-c55f-47a9-a653-114f08bb8073"></video></td>
<td align="center"><video src="https://github.com/harry0703/MoneyPrinterTurbo/assets/4928832/271f2fae-8283-44a0-8aa0-0ed8f9a6fa87"></video></td>
</tr>
</tbody>
</table>

16
sites/docs/zh/README.md Normal file
View File

@@ -0,0 +1,16 @@
---
home: true
heroImage: /hero.png
actions:
- text: 快速上手 →
link: /zh/guide/
type: primary
features:
- title: 多语言
details: 支持 中文 和 英文 视频文案;支持 多种语音 合成。
- title: 可维护性
details: 完整的 MVC架构代码 结构清晰,易于维护,支持 API 和 Web界面。
- title: 多模型支持
details: 支持 OpenAI、moonshot、Azure、gpt4free、one-api、通义千问、Google Gemini、Ollama 等多种模型接入。
footer: MIT Licensed | Copyright © 2024-present MoneyPrinterTurbo
---

View File

@@ -0,0 +1,157 @@
## 快速开始 🚀
<br>
只需提供一个视频 <b>主题</b> 或 <b>关键词</b> ,就可以全自动生成视频文案、视频素材、视频字幕、视频背景音乐,然后合成一个高清的短视频。
<br>
<h4>Web界面</h4>
![](/webui.jpg)
<h4>API界面</h4>
![](/api.jpg)
下载一键启动包,解压直接使用
### Windows
- 百度网盘: https://pan.baidu.com/s/1bpGjgQVE5sADZRn3A6F87w?pwd=xt16 提取码: xt16
下载后,建议先**双击执行** `update.bat` 更新到**最新代码**,然后双击 `start.bat` 启动 Web 界面
### 其他系统
还没有制作一键启动包,看下面的 **安装部署** 部分,建议使用 **docker** 部署,更加方便。
## 安装部署 📥
### 前提条件
- 尽量不要使用 **中文路径**,避免出现一些无法预料的问题
- 请确保你的 **网络** 是正常的VPN 需要打开`全局流量`模式
#### ① 克隆代码
```shell
git clone https://github.com/harry0703/MoneyPrinterTurbo.git
```
#### ② 修改配置文件
-`config.example.toml` 文件复制一份,命名为 `config.toml`
- 按照 `config.toml` 文件中的说明,配置好 `pexels_api_keys``llm_provider`,并根据 llm_provider 对应的服务商,配置相关的
API Key
#### ③ 配置大模型(LLM)
- 如果要使用 `GPT-4.0``GPT-3.5`,需要有 `OpenAI``API Key`,如果没有,可以将 `llm_provider` 设置为 `g4f` (
一个免费使用 GPT 的开源库 https://github.com/xtekky/gpt4free ,但是该免费的服务,稳定性较差,有时候可以用,有时候用不了)
- 或者可以使用到 [月之暗面](https://platform.moonshot.cn/console/api-keys) 申请。注册就送
15 元体验金,可以对话 1500 次左右。然后设置 `llm_provider="moonshot"``moonshot_api_key`
- 也可以使用 通义千问,具体请看配置文件里面的注释说明
### Docker 部署 🐳
#### ① 启动 Docker
如果未安装 Docker请先安装 https://www.docker.com/products/docker-desktop/
如果是 Windows 系统,请参考微软的文档:
1. https://learn.microsoft.com/zh-cn/windows/wsl/install
2. https://learn.microsoft.com/zh-cn/windows/wsl/tutorials/wsl-containers
```shell
cd MoneyPrinterTurbo
docker-compose up
```
#### ② 访问 Web 界面
打开浏览器,访问 http://0.0.0.0:8501
#### ③ 访问 API 文档
打开浏览器,访问 http://0.0.0.0:8080/docs 或者 http://0.0.0.0:8080/redoc
### 手动部署 📦
> 视频教程
- 完整的使用演示https://v.douyin.com/iFhnwsKY/
- 如何在 Windows 上部署https://v.douyin.com/iFyjoW3M
#### ① 创建虚拟环境
建议使用 [conda](https://conda.io/projects/conda/en/latest/user-guide/install/index.html) 创建 python 虚拟环境
```shell
git clone https://github.com/harry0703/MoneyPrinterTurbo.git
cd MoneyPrinterTurbo
conda create -n MoneyPrinterTurbo python=3.10
conda activate MoneyPrinterTurbo
pip install -r requirements.txt
```
#### ② 安装好 ImageMagick
###### Windows:
- 下载 https://imagemagick.org/archive/binaries/ImageMagick-7.1.1-30-Q16-x64-static.exe
- 安装下载好的 ImageMagick注意不要修改安装路径
- 修改 `配置文件 config.toml` 中的 `imagemagick_path` 为你的实际安装路径(如果安装的时候没有修改路径,直接取消注释即可)
###### MacOS:
```shell
brew install imagemagick
```
###### Ubuntu
```shell
sudo apt-get install imagemagick
```
###### CentOS
```shell
sudo yum install ImageMagick
```
#### ③ 启动 Web 界面 🌐
注意需要到 MoneyPrinterTurbo 项目 `根目录` 下执行以下命令
###### Windows
```bat
conda activate MoneyPrinterTurbo
webui.bat
```
###### MacOS or Linux
```shell
conda activate MoneyPrinterTurbo
sh webui.sh
```
启动后,会自动打开浏览器
#### ④ 启动 API 服务 🚀
```shell
python main.py
```
启动后,可以查看 `API文档` http://127.0.0.1:8080/docs 或者 http://127.0.0.1:8080/redoc 直接在线调试接口,快速体验。
## 许可证 📝
点击查看 [`LICENSE`](LICENSE) 文件
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=harry0703/MoneyPrinterTurbo&type=Date)](https://star-history.com/#harry0703/MoneyPrinterTurbo&Date)

View File

@@ -0,0 +1,4 @@
## 背景音乐 🎵
用于视频的背景音乐,位于项目的 `resource/songs` 目录下。
> 当前项目里面放了一些默认的音乐,来自于 YouTube 视频,如有侵权,请删除。

View File

@@ -0,0 +1,4 @@
## 配置要求 📦
- 建议最低 CPU 4核或以上内存 8G 或以上,显卡非必须
- Windows 10 或 MacOS 11.0 以上系统

123
sites/docs/zh/guide/faq.md Normal file
View File

@@ -0,0 +1,123 @@
## 常见问题 🤔
### ❓如何使用免费的OpenAI GPT-3.5模型?
[OpenAI宣布ChatGPT里面3.5已经免费了](https://openai.com/blog/start-using-chatgpt-instantly)有开发者将其封装成了API可以直接调用
**确保你安装和启动了docker服务**执行以下命令启动docker服务
```shell
docker run -p 3040:3040 missuo/freegpt35
```
启动成功后,修改 `config.toml` 中的配置
- `llm_provider` 设置为 `openai`
- `openai_api_key` 随便填写一个即可,比如 '123456'
- `openai_base_url` 改为 `http://localhost:3040/v1/`
- `openai_model_name` 改为 `gpt-3.5-turbo`
### ❓AttributeError: 'str' object has no attribute 'choices'`
这个问题是由于 OpenAI 或者其他 LLM没有返回正确的回复导致的。
大概率是网络原因, 使用 **VPN**,或者设置 `openai_base_url` 为你的代理 ,应该就可以解决了。
### ❓RuntimeError: No ffmpeg exe could be found
通常情况下ffmpeg 会被自动下载,并且会被自动检测到。
但是如果你的环境有问题,无法自动下载,可能会遇到如下错误:
```
RuntimeError: No ffmpeg exe could be found.
Install ffmpeg on your system, or set the IMAGEIO_FFMPEG_EXE environment variable.
```
此时你可以从 https://www.gyan.dev/ffmpeg/builds/ 下载ffmpeg解压后设置 `ffmpeg_path` 为你的实际安装路径即可。
```toml
[app]
# 请根据你的实际路径设置,注意 Windows 路径分隔符为 \\
ffmpeg_path = "C:\\Users\\harry\\Downloads\\ffmpeg.exe"
```
### ❓生成音频时报错或下载视频报错
[issue 56](https://github.com/harry0703/MoneyPrinterTurbo/issues/56)
```
failed to generate audio, maybe the network is not available.
if you are in China, please use a VPN.
```
[issue 44](https://github.com/harry0703/MoneyPrinterTurbo/issues/44)
```
failed to download videos, maybe the network is not available.
if you are in China, please use a VPN.
```
这个大概率是网络原因无法访问境外的服务请使用VPN解决。
### ❓ImageMagick is not installed on your computer
[issue 33](https://github.com/harry0703/MoneyPrinterTurbo/issues/33)
1. 按照 `示例配置` 里面提供的 `下载地址`
,安装 https://imagemagick.org/archive/binaries/ImageMagick-7.1.1-29-Q16-x64-static.exe, 用静态库
2. 不要安装在中文路径里面,避免出现一些无法预料的问题
[issue 54](https://github.com/harry0703/MoneyPrinterTurbo/issues/54#issuecomment-2017842022)
如果是linux系统可以手动安装参考 https://cn.linux-console.net/?p=16978
感谢 [@wangwenqiao666](https://github.com/wangwenqiao666)的研究探索
### ❓ImageMagick的安全策略阻止了与临时文件@/tmp/tmpur5hyyto.txt相关的操作
[issue 92](https://github.com/harry0703/MoneyPrinterTurbo/issues/92)
可以在ImageMagick的配置文件policy.xml中找到这些策略。
这个文件通常位于 /etc/ImageMagick-`X`/ 或 ImageMagick 安装目录的类似位置。
修改包含`pattern="@"`的条目,将`rights="none"`更改为`rights="read|write"`以允许对文件的读写操作。
感谢 [@chenhengzh](https://github.com/chenhengzh)的研究探索
### ❓OSError: [Errno 24] Too many open files
[issue 100](https://github.com/harry0703/MoneyPrinterTurbo/issues/100)
这个问题是由于系统打开文件数限制导致的,可以通过修改系统的文件打开数限制来解决。
查看当前限制
```shell
ulimit -n
```
如果过低,可以调高一些,比如
```shell
ulimit -n 10240
```
### ❓AttributeError: module 'PIL.Image' has no attribute 'ANTIALIAS'
[issue 101](https://github.com/harry0703/MoneyPrinterTurbo/issues/101),
[issue 83](https://github.com/harry0703/MoneyPrinterTurbo/issues/83),
[issue 70](https://github.com/harry0703/MoneyPrinterTurbo/issues/70)
先看下当前的 Pillow 版本是多少
```shell
pip list |grep Pillow
```
如果是 10.x 的版本,可以尝试下降级看看,有用户反馈降级后正常
```shell
pip uninstall Pillow
pip install Pillow==9.5.0
# 或者降级到 8.4.0
pip install Pillow==8.4.0
```

View File

@@ -0,0 +1,31 @@
## 功能特性 🎯
- [x] 完整的 **MVC架构**,代码 **结构清晰**,易于维护,支持 `API``Web界面`
- [x] 支持视频文案 **AI自动生成**,也可以**自定义文案**
- [x] 支持多种 **高清视频** 尺寸
- [x] 竖屏 9:16`1080x1920`
- [x] 横屏 16:9`1920x1080`
- [x] 支持 **批量视频生成**,可以一次生成多个视频,然后选择一个最满意的
- [x] 支持 **视频片段时长**设置,方便调节素材切换频率
- [x] 支持 **中文****英文** 视频文案
- [x] 支持 **多种语音** 合成
- [x] 支持 **字幕生成**,可以调整 `字体``位置``颜色``大小`,同时支持`字幕描边`设置
- [x] 支持 **背景音乐**,随机或者指定音乐文件,可设置`背景音乐音量`
- [x] 视频素材来源 **高清**,而且 **无版权**
- [x] 支持 **OpenAI**、**moonshot**、**Azure**、**gpt4free**、**one-api**、**通义千问**、**Google Gemini**、**Ollama** 等多种模型接入
❓[如何使用免费的 **OpenAI GPT-3.5
** 模型?](https://github.com/harry0703/MoneyPrinterTurbo?tab=readme-ov-file#%E5%B8%B8%E8%A7%81%E9%97%AE%E9%A2%98-)
### 后期计划 📅
- [ ] GPT-SoVITS 配音支持
- [ ] 优化语音合成,利用大模型,使其合成的声音,更加自然,情绪更加丰富
- [ ] 增加视频转场效果,使其看起来更加的流畅
- [ ] 增加更多视频素材来源,优化视频素材和文案的匹配度
- [ ] 增加视频长度选项:短、中、长
- [ ] 增加免费网络代理让访问OpenAI和素材下载不再受限
- [ ] 可以使用自己的素材
- [ ] 朗读声音和背景音乐,提供实时试听
- [ ] 支持更多的语音合成服务商,比如 OpenAI TTS
- [ ] 自动上传到YouTube平台

View File

@@ -0,0 +1,4 @@
## 反馈建议 📢
- 可以提交 [issue](https://github.com/harry0703/MoneyPrinterTurbo/issues)
或者 [pull request](https://github.com/harry0703/MoneyPrinterTurbo/pulls)。

View File

@@ -0,0 +1,4 @@
## 参考项目 📚
该项目基于 https://github.com/FujiwaraChoki/MoneyPrinter 重构而来,做了大量的优化,增加了更多的功能。
感谢原作者的开源精神。

View File

@@ -0,0 +1,9 @@
## 特别感谢 🙏
由于该项目的 **部署****使用**,对于一些小白用户来说,还是 **有一定的门槛**,在此特别感谢
**录咖AI智能 多媒体服务平台)** 网站基于该项目,提供的免费`AI视频生成器`服务,可以不用部署,直接在线使用,非常方便。
- 中文版https://reccloud.cn
- 英文版https://reccloud.com
![](/reccloud.cn.jpg)

View File

@@ -0,0 +1,5 @@
## 语音合成 🗣
所有支持的声音列表,可以查看:[声音列表](/voice-list.txt)
2024-04-16 v1.1.2 新增了9种Azure的语音合成声音需要配置API KEY该声音合成的更加真实。

View File

@@ -0,0 +1,3 @@
## 字幕字体 🅰
用于视频字幕的渲染,位于项目的 `resource/fonts` 目录下,你也可以放进去自己的字体。

Some files were not shown because too many files have changed in this diff Show More