Spaces:
Running
Running
Upload 121 files
Browse filesThis view is limited to 50 files because it contains too many changes.
See raw diff
- .gitattributes +13 -0
- Dockerfile +63 -0
- LICENSE +21 -0
- README-en.md +115 -0
- README-ja.md +84 -0
- README.md +118 -10
- app/__init__.py +0 -0
- app/asgi.py +90 -0
- app/config/__init__.py +56 -0
- app/config/config.py +85 -0
- app/controllers/base.py +31 -0
- app/controllers/manager/base_manager.py +64 -0
- app/controllers/manager/memory_manager.py +18 -0
- app/controllers/manager/redis_manager.py +56 -0
- app/controllers/ping.py +14 -0
- app/controllers/v1/base.py +11 -0
- app/controllers/v1/llm.py +93 -0
- app/controllers/v1/video.py +271 -0
- app/controllers/v2/base.py +11 -0
- app/controllers/v2/script.py +170 -0
- app/models/__init__.py +0 -0
- app/models/const.py +25 -0
- app/models/exception.py +28 -0
- app/models/schema.py +391 -0
- app/models/schema_v2.py +63 -0
- app/router.py +21 -0
- app/services/SDE/prompt.py +97 -0
- app/services/SDE/short_drama_explanation.py +456 -0
- app/services/SDP/generate_script_short.py +37 -0
- app/services/SDP/utils/short_schema.py +60 -0
- app/services/SDP/utils/step1_subtitle_analyzer_openai.py +157 -0
- app/services/SDP/utils/step5_merge_script.py +69 -0
- app/services/SDP/utils/utils.py +45 -0
- app/services/__init__.py +0 -0
- app/services/audio_merger.py +171 -0
- app/services/clip_video.py +237 -0
- app/services/generate_narration_script.py +264 -0
- app/services/generate_video.py +393 -0
- app/services/llm.py +808 -0
- app/services/material.py +561 -0
- app/services/merger_video.py +662 -0
- app/services/script_service.py +400 -0
- app/services/state.py +122 -0
- app/services/subtitle.py +462 -0
- app/services/subtitle_merger.py +202 -0
- app/services/task.py +398 -0
- app/services/update_script.py +266 -0
- app/services/video.py +365 -0
- app/services/video_service.py +56 -0
- app/services/voice.py +1469 -0
.gitattributes
CHANGED
@@ -33,3 +33,16 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
|
33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
36 |
+
docs/check-en.png filter=lfs diff=lfs merge=lfs -text
|
37 |
+
docs/check-zh.png filter=lfs diff=lfs merge=lfs -text
|
38 |
+
docs/img001-en.png filter=lfs diff=lfs merge=lfs -text
|
39 |
+
docs/img001-zh.png filter=lfs diff=lfs merge=lfs -text
|
40 |
+
docs/img004-en.png filter=lfs diff=lfs merge=lfs -text
|
41 |
+
docs/img004-zh.png filter=lfs diff=lfs merge=lfs -text
|
42 |
+
docs/img005-zh.png filter=lfs diff=lfs merge=lfs -text
|
43 |
+
docs/img006-en.png filter=lfs diff=lfs merge=lfs -text
|
44 |
+
docs/img006-zh.png filter=lfs diff=lfs merge=lfs -text
|
45 |
+
docs/img007-en.png filter=lfs diff=lfs merge=lfs -text
|
46 |
+
docs/img007-zh.png filter=lfs diff=lfs merge=lfs -text
|
47 |
+
docs/index-en.png filter=lfs diff=lfs merge=lfs -text
|
48 |
+
docs/index-zh.png filter=lfs diff=lfs merge=lfs -text
|
Dockerfile
ADDED
@@ -0,0 +1,63 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# 构建阶段
|
2 |
+
FROM python:3.10-slim-bullseye as builder
|
3 |
+
|
4 |
+
# 设置工作目录
|
5 |
+
WORKDIR /build
|
6 |
+
|
7 |
+
# 安装构建依赖
|
8 |
+
RUN apt-get update && apt-get install -y \
|
9 |
+
git \
|
10 |
+
git-lfs \
|
11 |
+
&& rm -rf /var/lib/apt/lists/*
|
12 |
+
|
13 |
+
# 创建虚拟环境
|
14 |
+
RUN python -m venv /opt/venv
|
15 |
+
ENV PATH="/opt/venv/bin:$PATH"
|
16 |
+
|
17 |
+
# 首先安装 PyTorch(因为它是最大的依赖)
|
18 |
+
RUN pip install --no-cache-dir torch torchvision torchaudio
|
19 |
+
|
20 |
+
# 然后安装其他依赖
|
21 |
+
COPY requirements.txt .
|
22 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
23 |
+
|
24 |
+
# 运行阶段
|
25 |
+
FROM python:3.10-slim-bullseye
|
26 |
+
|
27 |
+
# 设置工作目录
|
28 |
+
WORKDIR /NarratoAI
|
29 |
+
|
30 |
+
# 从builder阶段复制虚拟环境
|
31 |
+
COPY --from=builder /opt/venv /opt/venv
|
32 |
+
ENV PATH="/opt/venv/bin:$PATH"
|
33 |
+
|
34 |
+
# 安装运行时依赖
|
35 |
+
RUN apt-get update && apt-get install -y \
|
36 |
+
imagemagick \
|
37 |
+
ffmpeg \
|
38 |
+
wget \
|
39 |
+
git-lfs \
|
40 |
+
&& rm -rf /var/lib/apt/lists/* \
|
41 |
+
&& sed -i '/<policy domain="path" rights="none" pattern="@\*"/d' /etc/ImageMagick-6/policy.xml
|
42 |
+
|
43 |
+
# 设置环境变量
|
44 |
+
ENV PYTHONPATH="/NarratoAI" \
|
45 |
+
PYTHONUNBUFFERED=1 \
|
46 |
+
PYTHONDONTWRITEBYTECODE=1
|
47 |
+
|
48 |
+
# 设置目录权限
|
49 |
+
RUN chmod 777 /NarratoAI
|
50 |
+
|
51 |
+
# 安装git lfs
|
52 |
+
RUN git lfs install
|
53 |
+
|
54 |
+
# 复制应用代码
|
55 |
+
COPY . .
|
56 |
+
|
57 |
+
# 暴露端口
|
58 |
+
EXPOSE 8501 8080
|
59 |
+
|
60 |
+
# 使用脚本作为入口点
|
61 |
+
COPY docker-entrypoint.sh /usr/local/bin/
|
62 |
+
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
63 |
+
ENTRYPOINT ["docker-entrypoint.sh"]
|
LICENSE
ADDED
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
MIT License
|
2 |
+
|
3 |
+
Copyright (c) 2024 linyq
|
4 |
+
|
5 |
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
6 |
+
of this software and associated documentation files (the "Software"), to deal
|
7 |
+
in the Software without restriction, including without limitation the rights
|
8 |
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
9 |
+
copies of the Software, and to permit persons to whom the Software is
|
10 |
+
furnished to do so, subject to the following conditions:
|
11 |
+
|
12 |
+
The above copyright notice and this permission notice shall be included in all
|
13 |
+
copies or substantial portions of the Software.
|
14 |
+
|
15 |
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
16 |
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
17 |
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
18 |
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
19 |
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
20 |
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
21 |
+
SOFTWARE.
|
README-en.md
ADDED
@@ -0,0 +1,115 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
<div align="center">
|
2 |
+
<h1 align="center" style="font-size: 2cm;"> NarratoAI 😎📽️ </h1>
|
3 |
+
<h3 align="center">An all-in-one AI-powered tool for film commentary and automated video editing.🎬🎞️ </h3>
|
4 |
+
|
5 |
+
|
6 |
+
<h3>📖 English | <a href="README.md">简体中文</a> | <a href="README-ja.md">日本語</a> </h3>
|
7 |
+
<div align="center">
|
8 |
+
|
9 |
+
[//]: # ( <a href="https://trendshift.io/repositories/8731" target="_blank"><img src="https://trendshift.io/api/badge/repositories/8731" alt="harry0703%2FNarratoAI | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>)
|
10 |
+
</div>
|
11 |
+
<br>
|
12 |
+
NarratoAI is an automated video narration tool that provides an all-in-one solution for script writing, automated video editing, voice-over, and subtitle generation, powered by LLM to enhance efficient content creation.
|
13 |
+
<br>
|
14 |
+
|
15 |
+
[](https://github.com/linyqh/NarratoAI)
|
16 |
+
[](https://github.com/linyqh/NarratoAI/blob/main/LICENSE)
|
17 |
+
[](https://github.com/linyqh/NarratoAI/issues)
|
18 |
+
[](https://github.com/linyqh/NarratoAI/stargazers)
|
19 |
+
|
20 |
+
<a href="https://discord.com/invite/V2pbAqqQNb" target="_blank">💬 Join the open source community to get project updates and the latest news.</a>
|
21 |
+
|
22 |
+
<h2><a href="https://p9mf6rjv3c.feishu.cn/wiki/SP8swLLZki5WRWkhuFvc2CyInDg?from=from_copylink" target="_blank">🎉🎉🎉 Official Documentation 🎉🎉🎉</a> </h2>
|
23 |
+
<h3>Home</h3>
|
24 |
+
|
25 |
+

|
26 |
+
|
27 |
+
<h3>Video Review Interface</h3>
|
28 |
+
|
29 |
+

|
30 |
+
|
31 |
+
</div>
|
32 |
+
|
33 |
+
## Latest News
|
34 |
+
- 2025.05.11 Released new version 0.6.0, supports **short drama commentary** and optimized editing process
|
35 |
+
- 2025.03.06 Released new version 0.5.2, supports DeepSeek R1 and DeepSeek V3 models for short drama mixing
|
36 |
+
- 2024.12.16 Released new version 0.3.9, supports Alibaba Qwen2-VL model for video understanding; supports short drama mixing
|
37 |
+
- 2024.11.24 Opened Discord community: https://discord.com/invite/V2pbAqqQNb
|
38 |
+
- 2024.11.11 Migrated open source community, welcome to join! [Join the official community](https://github.com/linyqh/NarratoAI/wiki)
|
39 |
+
- 2024.11.10 Released official documentation, details refer to [Official Documentation](https://p9mf6rjv3c.feishu.cn/wiki/SP8swLLZki5WRWkhuFvc2CyInDg)
|
40 |
+
- 2024.11.10 Released new version v0.3.5; optimized video editing process,
|
41 |
+
|
42 |
+
## Major Benefits 🎉
|
43 |
+
From now on, fully support DeepSeek model! Register to enjoy 20 million free tokens (worth 14 yuan platform quota), editing a 10-minute video only costs 0.1 yuan!
|
44 |
+
|
45 |
+
🔥 Quick benefits:
|
46 |
+
1️⃣ Click the link to register: https://cloud.siliconflow.cn/i/pyOKqFCV
|
47 |
+
2️⃣ Log in with your phone number, **be sure to fill in the invitation code: pyOKqFCV**
|
48 |
+
3️⃣ Receive a 14 yuan quota, experience high cost-effective AI editing quickly!
|
49 |
+
|
50 |
+
💡 Low cost, high creativity:
|
51 |
+
Silicon Flow API Key can be integrated with one click, doubling intelligent editing efficiency!
|
52 |
+
(Note: The invitation code is the only proof for benefit collection, automatically credited after registration)
|
53 |
+
|
54 |
+
Immediately take action to unlock your AI productivity with "pyOKqFCV"!
|
55 |
+
|
56 |
+
😊 Update Steps:
|
57 |
+
Integration Package: Click update.bat one-click update script
|
58 |
+
Code Build: Use git pull to fetch the latest code
|
59 |
+
|
60 |
+
## Announcement 📢
|
61 |
+
_**Note⚠️: Recently, someone has been impersonating the author on x (Twitter) to issue tokens on the pump.fun platform! This is a scam!!! Do not be deceived! Currently, NarratoAI has not made any official promotions on x (Twitter), please be cautious**_
|
62 |
+
|
63 |
+
Below is a screenshot of this person's x (Twitter) homepage
|
64 |
+
|
65 |
+
<img src="https://github.com/user-attachments/assets/c492ab99-52cd-4ba2-8695-1bd2073ecf12" alt="Screenshot_20250109_114131_Samsung Internet" style="width:30%; height:auto;">
|
66 |
+
|
67 |
+
## Future Plans 🥳
|
68 |
+
- [x] Windows Integration Pack Release
|
69 |
+
- [x] Optimized the story generation process and improved the generation effect
|
70 |
+
- [x] Released version 0.3.5 integration package
|
71 |
+
- [x] Support Alibaba Qwen2-VL large model for video understanding
|
72 |
+
- [x] Support short drama commentary
|
73 |
+
- [x] One-click merge materials
|
74 |
+
- [x] One-click transcription
|
75 |
+
- [x] One-click clear cache
|
76 |
+
- [ ] Support exporting to Jianying drafts
|
77 |
+
- [X] Support short drama commentary
|
78 |
+
- [ ] Character face matching
|
79 |
+
- [ ] Support automatic matching based on voiceover, script, and video materials
|
80 |
+
- [ ] Support more TTS engines
|
81 |
+
- [ ] ...
|
82 |
+
|
83 |
+
## System Requirements 📦
|
84 |
+
|
85 |
+
- Recommended minimum: CPU with 4 cores or more, 8GB RAM or more, GPU is not required
|
86 |
+
- Windows 10/11 or MacOS 11.0 or above
|
87 |
+
- [Python 3.12+](https://www.python.org/downloads/)
|
88 |
+
|
89 |
+
## Feedback & Suggestions 📢
|
90 |
+
|
91 |
+
👏 1. You can submit [issue](https://github.com/linyqh/NarratoAI/issues) or [pull request](https://github.com/linyqh/NarratoAI/pulls)
|
92 |
+
|
93 |
+
💬 2. [Join the open source community exchange group](https://github.com/linyqh/NarratoAI/wiki)
|
94 |
+
|
95 |
+
📷 3. Follow the official account [NarratoAI助手] to grasp the latest news
|
96 |
+
|
97 |
+
## Reference Projects 📚
|
98 |
+
- https://github.com/FujiwaraChoki/MoneyPrinter
|
99 |
+
- https://github.com/harry0703/MoneyPrinterTurbo
|
100 |
+
|
101 |
+
This project was refactored based on the above projects with the addition of video narration features. Thanks to the original authors for their open-source spirit 🥳🥳🥳
|
102 |
+
|
103 |
+
## Buy the Author a Cup of Coffee ☕️
|
104 |
+
<div style="display: flex; justify-content: space-between;">
|
105 |
+
<img src="https://github.com/user-attachments/assets/5038ccfb-addf-4db1-9966-99415989fd0c" alt="Image 1" style="width: 350px; height: 350px; margin: auto;"/>
|
106 |
+
<img src="https://github.com/user-attachments/assets/07d4fd58-02f0-425c-8b59-2ab94b4f09f8" alt="Image 2" style="width: 350px; height: 350px; margin: auto;"/>
|
107 |
+
</div>
|
108 |
+
|
109 |
+
## License 📝
|
110 |
+
|
111 |
+
Click to view [`LICENSE`](LICENSE) file
|
112 |
+
|
113 |
+
## Star History
|
114 |
+
|
115 |
+
[](https://star-history.com/#linyqh/NarratoAI&Date)
|
README-ja.md
ADDED
@@ -0,0 +1,84 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
<div align="center">
|
2 |
+
<h1 align="center" style="font-size: 2cm;"> NarratoAI 😎📽️ </h1>
|
3 |
+
<h3 align="center">一体型AI映画解説および自動ビデオ編集ツール🎬🎞️ </h3>
|
4 |
+
|
5 |
+
<h3>📖 <a href="README-cn.md">简体中文</a> | <a href="README.md">English</a> | 日本語 </h3>
|
6 |
+
<div align="center">
|
7 |
+
|
8 |
+
[//]: # ( <a href="https://trendshift.io/repositories/8731" target="_blank"><img src="https://trendshift.io/api/badge/repositories/8731" alt="harry0703%2FNarratoAI | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>)
|
9 |
+
</div>
|
10 |
+
<br>
|
11 |
+
NarratoAIは、LLMを活用してスクリプト作成、自動ビデオ編集、ナレーション、字幕生成の一体型ソリューションを提供する自動化ビデオナレーションツールです。
|
12 |
+
<br>
|
13 |
+
|
14 |
+
[](https://github.com/linyqh/NarratoAI)
|
15 |
+
[](https://github.com/linyqh/NarratoAI/blob/main/LICENSE)
|
16 |
+
[](https://github.com/linyqh/NarratoAI/issues)
|
17 |
+
[](https://github.com/linyqh/NarratoAI/stargazers)
|
18 |
+
|
19 |
+
<a href="https://discord.gg/uVAJftcm" target="_blank">💬 Discordオープンソースコミュニティに参加して、プロジェクトの最新情報を入手しましょう。</a>
|
20 |
+
|
21 |
+
<h2><a href="https://p9mf6rjv3c.feishu.cn/wiki/SP8swLLZki5WRWkhuFvc2CyInDg?from=from_copylink" target="_blank">🎉🎉🎉 公式ドキュメント 🎉🎉🎉</a> </h2>
|
22 |
+
<h3>ホーム</h3>
|
23 |
+
|
24 |
+

|
25 |
+
|
26 |
+
<h3>ビデオレビューインターフェース</h3>
|
27 |
+
|
28 |
+

|
29 |
+
|
30 |
+
</div>
|
31 |
+
|
32 |
+
## 最新情報
|
33 |
+
- 2024.11.24 Discordコミュニティ開設:https://discord.gg/uVAJftcm
|
34 |
+
- 2024.11.11 オープンソースコミュニティに移行、参加を歓迎します! [公式コミュニティに参加](https://github.com/linyqh/NarratoAI/wiki)
|
35 |
+
- 2024.11.10 公式ドキュメント公開、詳細は [公式ドキュメント](https://p9mf6rjv3c.feishu.cn/wiki/SP8swLLZki5WRWkhuFvc2CyInDg) を参照
|
36 |
+
- 2024.11.10 新バージョンv0.3.5リリース;ビデオ編集プロセスの最適化
|
37 |
+
|
38 |
+
## 今後の計画 🥳
|
39 |
+
- [x] Windows統合パックリリース
|
40 |
+
- [x] ストーリー生成プロセスの最適化、生成効果の向上
|
41 |
+
- [x] バージョン0.3.5統合パックリリース
|
42 |
+
- [x] アリババQwen2-VL大規模モデルのビデオ理解サポート
|
43 |
+
- [x] 短編ドラマの解説サポート
|
44 |
+
- [x] 一クリックで素材を統合
|
45 |
+
- [x] 一クリックで文字起こし
|
46 |
+
- [x] 一クリックでキャッシュをクリア
|
47 |
+
- [ ] ジャン映草稿のエクスポートをサポート
|
48 |
+
- [ ] 主役の顔のマッチング
|
49 |
+
- [ ] 音声、スクリプト、ビデオ素材に基づいて自動マッチングをサポート
|
50 |
+
- [ ] より多くのTTSエンジンをサポート
|
51 |
+
- [ ] ...
|
52 |
+
|
53 |
+
## システム要件 📦
|
54 |
+
|
55 |
+
- 推奨最低:CPU 4コア以上、メモリ8GB以上、GPUは必須ではありません
|
56 |
+
- Windows 10またはMacOS 11.0以上
|
57 |
+
|
58 |
+
## フィードバックと提案 📢
|
59 |
+
|
60 |
+
👏 1. [issue](https://github.com/linyqh/NarratoAI/issues)または[pull request](https://github.com/linyqh/NarratoAI/pulls)を提出できます
|
61 |
+
|
62 |
+
💬 2. [オープンソースコミュニティ交流グループに参加](https://github.com/linyqh/NarratoAI/wiki)
|
63 |
+
|
64 |
+
📷 3. 公式アカウント【NarratoAI助手】をフォローして最新情報を入手
|
65 |
+
|
66 |
+
## 参考プロジェクト 📚
|
67 |
+
- https://github.com/FujiwaraChoki/MoneyPrinter
|
68 |
+
- https://github.com/harry0703/MoneyPrinterTurbo
|
69 |
+
|
70 |
+
このプロジェクトは上記のプロジェクトを基にリファクタリングされ、映画解説機能が追加されました。オリジナルの作者に感謝します 🥳🥳🥳
|
71 |
+
|
72 |
+
## 作者にコーヒーを一杯おごる ☕️
|
73 |
+
<div style="display: flex; justify-content: space-between;">
|
74 |
+
<img src="https://github.com/user-attachments/assets/5038ccfb-addf-4db1-9966-99415989fd0c" alt="Image 1" style="width: 350px; height: 350px; margin: auto;"/>
|
75 |
+
<img src="https://github.com/user-attachments/assets/07d4fd58-02f0-425c-8b59-2ab94b4f09f8" alt="Image 2" style="width: 350px; height: 350px; margin: auto;"/>
|
76 |
+
</div>
|
77 |
+
|
78 |
+
## ライセンス 📝
|
79 |
+
|
80 |
+
[`LICENSE`](LICENSE) ファイルをクリックして表示
|
81 |
+
|
82 |
+
## Star History
|
83 |
+
|
84 |
+
[](https://star-history.com/#linyqh/NarratoAI&Date)
|
README.md
CHANGED
@@ -1,10 +1,118 @@
|
|
1 |
-
|
2 |
-
|
3 |
-
|
4 |
-
|
5 |
-
|
6 |
-
|
7 |
-
|
8 |
-
|
9 |
-
|
10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
|
2 |
+
<div align="center">
|
3 |
+
<h1 align="center" style="font-size: 2cm;"> NarratoAI 😎📽️ </h1>
|
4 |
+
<h3 align="center">一站式 AI 影视解说+自动化剪辑工具🎬🎞️ </h3>
|
5 |
+
|
6 |
+
|
7 |
+
<h3>📖 <a href="README-en.md">English</a> | 简体中文 | <a href="README-ja.md">日本語</a> </h3>
|
8 |
+
<div align="center">
|
9 |
+
|
10 |
+
[//]: # ( <a href="https://trendshift.io/repositories/8731" target="_blank"><img src="https://trendshift.io/api/badge/repositories/8731" alt="harry0703%2FNarratoAI | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>)
|
11 |
+
</div>
|
12 |
+
<br>
|
13 |
+
NarratoAI 是一个自动化影视解说工具,基于LLM实现文案撰写、自动化视频剪辑、配音和字幕生成的一站式流程,助力高效内容创作。
|
14 |
+
<br>
|
15 |
+
|
16 |
+
[](https://github.com/linyqh/NarratoAI)
|
17 |
+
[](https://github.com/linyqh/NarratoAI/blob/main/LICENSE)
|
18 |
+
[](https://github.com/linyqh/NarratoAI/issues)
|
19 |
+
[](https://github.com/linyqh/NarratoAI/stargazers)
|
20 |
+
|
21 |
+
<a href="https://discord.com/invite/V2pbAqqQNb" target="_blank">💬 加入 discord 开源社区,获取项目动态和最新资讯。</a>
|
22 |
+
|
23 |
+
<h2><a href="https://p9mf6rjv3c.feishu.cn/wiki/SP8swLLZki5WRWkhuFvc2CyInDg?from=from_copylink" target="_blank">🎉🎉🎉 官方文档 🎉🎉🎉</a> </h2>
|
24 |
+
<h3>首页</h3>
|
25 |
+
|
26 |
+

|
27 |
+
|
28 |
+
<h3>视频审查界面</h3>
|
29 |
+
|
30 |
+

|
31 |
+
|
32 |
+
</div>
|
33 |
+
|
34 |
+
## 最新资讯
|
35 |
+
- 2025.05.11 发布新版本 0.6.0,支持 **短剧解说** 和 优化剪辑流程
|
36 |
+
- 2025.03.06 发布新版本 0.5.2,支持 DeepSeek R1 和 DeepSeek V3 模型进行短剧混剪
|
37 |
+
- 2024.12.16 发布新版本 0.3.9,支持阿里 Qwen2-VL 模型理解视频;支持短剧混剪
|
38 |
+
- 2024.11.24 开通 discord 社群:https://discord.com/invite/V2pbAqqQNb
|
39 |
+
- 2024.11.11 迁移开源社群,欢迎加入! [加入官方社群](https://github.com/linyqh/NarratoAI/wiki)
|
40 |
+
- 2024.11.10 发布官方文档,详情参见 [官方文档](https://p9mf6rjv3c.feishu.cn/wiki/SP8swLLZki5WRWkhuFvc2CyInDg)
|
41 |
+
- 2024.11.10 发布新版本 v0.3.5;优化视频剪辑流程,
|
42 |
+
|
43 |
+
## 重磅福利 🎉
|
44 |
+
即日起全面支持DeepSeek模型!注册即享2000万免费Token(价值14元平台配额),剪辑10分钟视频仅需0.1元!
|
45 |
+
|
46 |
+
🔥 快速领福利:
|
47 |
+
1️⃣ 点击链接注册:https://cloud.siliconflow.cn/i/pyOKqFCV
|
48 |
+
2️⃣ 使用手机号登录,**务必填写邀请码:pyOKqFCV**
|
49 |
+
3️⃣ 领取14元配额,极速体验高性价比AI剪辑
|
50 |
+
|
51 |
+
💡 小成本大创作:
|
52 |
+
硅基流动API Key一键接入,智能剪辑效率翻倍!
|
53 |
+
(注:邀请码为福利领取唯一凭证,注册后自动到账)
|
54 |
+
|
55 |
+
立即行动,用「pyOKqFCV」解锁你的AI生产力!
|
56 |
+
|
57 |
+
😊 更新步骤:
|
58 |
+
整合包:点击 update.bat 一键更新脚本
|
59 |
+
代码构建:使用 git pull 拉去最新代码
|
60 |
+
|
61 |
+
## 公告 📢
|
62 |
+
_**注意⚠️:近期在 x (推特) 上发现有人冒充作者在 pump.fun 平台上发行代币! 这是骗子!!! 不要被割了韭菜
|
63 |
+
!!!目前 NarratoAI 没有在 x(推特) 上做任何官方宣传,注意甄别**_
|
64 |
+
|
65 |
+
下面是此人 x(推特) 首页截图
|
66 |
+
|
67 |
+
<img src="https://github.com/user-attachments/assets/c492ab99-52cd-4ba2-8695-1bd2073ecf12" alt="Screenshot_20250109_114131_Samsung Internet" style="width:30%; height:auto;">
|
68 |
+
|
69 |
+
## 未来计划 🥳
|
70 |
+
- [x] windows 整合包发布
|
71 |
+
- [x] 优化剧情生成流程,提升生成效果
|
72 |
+
- [x] 发布 0.3.5 整合包
|
73 |
+
- [x] 支持阿里 Qwen2-VL 大模型理解视频
|
74 |
+
- [x] 支持短剧混剪
|
75 |
+
- [x] 一键合并素材
|
76 |
+
- [x] 一键转录
|
77 |
+
- [x] 一键清理缓存
|
78 |
+
- [ ] 支持导出剪映草稿
|
79 |
+
- [X] 支持短剧解说
|
80 |
+
- [ ] 主角人脸匹配
|
81 |
+
- [ ] 支持根据口播,文案,视频素材自动匹配
|
82 |
+
- [ ] 支持更多 TTS 引擎
|
83 |
+
- [ ] ...
|
84 |
+
|
85 |
+
## 配置要求 📦
|
86 |
+
|
87 |
+
- 建议最低 CPU 4核或以上,内存 8G 或以上,显卡非必须
|
88 |
+
- Windows 10/11 或 MacOS 11.0 以上系统
|
89 |
+
- [Python 3.12+](https://www.python.org/downloads/)
|
90 |
+
|
91 |
+
## 反馈建议 📢
|
92 |
+
|
93 |
+
👏 1. 可以提交 [issue](https://github.com/linyqh/NarratoAI/issues)或者 [pull request](https://github.com/linyqh/NarratoAI/pulls)
|
94 |
+
|
95 |
+
💬 2. [加入开源社区交流群](https://github.com/linyqh/NarratoAI/wiki)
|
96 |
+
|
97 |
+
📷 3. 关注公众号【NarratoAI助手】,掌握最新资讯
|
98 |
+
|
99 |
+
## 参考项目 📚
|
100 |
+
- https://github.com/FujiwaraChoki/MoneyPrinter
|
101 |
+
- https://github.com/harry0703/MoneyPrinterTurbo
|
102 |
+
|
103 |
+
该项目基于以上项目重构而来,增加了影视解说功能,感谢大佬的开源精神 🥳����🥳
|
104 |
+
|
105 |
+
## 请作者喝一杯咖啡 ☕️
|
106 |
+
<div style="display: flex; justify-content: space-between;">
|
107 |
+
<img src="https://github.com/user-attachments/assets/5038ccfb-addf-4db1-9966-99415989fd0c" alt="Image 1" style="width: 350px; height: 350px; margin: auto;"/>
|
108 |
+
<img src="https://github.com/user-attachments/assets/07d4fd58-02f0-425c-8b59-2ab94b4f09f8" alt="Image 2" style="width: 350px; height: 350px; margin: auto;"/>
|
109 |
+
</div>
|
110 |
+
|
111 |
+
## 许可证 📝
|
112 |
+
|
113 |
+
点击查看 [`LICENSE`](LICENSE) 文件
|
114 |
+
|
115 |
+
## Star History
|
116 |
+
|
117 |
+
[](https://star-history.com/#linyqh/NarratoAI&Date)
|
118 |
+
|
app/__init__.py
ADDED
File without changes
|
app/asgi.py
ADDED
@@ -0,0 +1,90 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"""Application implementation - ASGI."""
|
2 |
+
|
3 |
+
import os
|
4 |
+
|
5 |
+
from fastapi import FastAPI, Request
|
6 |
+
from fastapi.exceptions import RequestValidationError
|
7 |
+
from fastapi.responses import JSONResponse
|
8 |
+
from loguru import logger
|
9 |
+
from fastapi.staticfiles import StaticFiles
|
10 |
+
from fastapi.middleware.cors import CORSMiddleware
|
11 |
+
|
12 |
+
from app.config import config
|
13 |
+
from app.models.exception import HttpException
|
14 |
+
from app.router import root_api_router
|
15 |
+
from app.utils import utils
|
16 |
+
from app.utils import ffmpeg_utils
|
17 |
+
|
18 |
+
|
19 |
+
def exception_handler(request: Request, e: HttpException):
|
20 |
+
return JSONResponse(
|
21 |
+
status_code=e.status_code,
|
22 |
+
content=utils.get_response(e.status_code, e.data, e.message),
|
23 |
+
)
|
24 |
+
|
25 |
+
|
26 |
+
def validation_exception_handler(request: Request, e: RequestValidationError):
|
27 |
+
return JSONResponse(
|
28 |
+
status_code=400,
|
29 |
+
content=utils.get_response(
|
30 |
+
status=400, data=e.errors(), message="field required"
|
31 |
+
),
|
32 |
+
)
|
33 |
+
|
34 |
+
|
35 |
+
def get_application() -> FastAPI:
|
36 |
+
"""Initialize FastAPI application.
|
37 |
+
|
38 |
+
Returns:
|
39 |
+
FastAPI: Application object instance.
|
40 |
+
|
41 |
+
"""
|
42 |
+
instance = FastAPI(
|
43 |
+
title=config.project_name,
|
44 |
+
description=config.project_description,
|
45 |
+
version=config.project_version,
|
46 |
+
debug=False,
|
47 |
+
)
|
48 |
+
instance.include_router(root_api_router)
|
49 |
+
instance.add_exception_handler(HttpException, exception_handler)
|
50 |
+
instance.add_exception_handler(RequestValidationError, validation_exception_handler)
|
51 |
+
return instance
|
52 |
+
|
53 |
+
|
54 |
+
app = get_application()
|
55 |
+
|
56 |
+
# Configures the CORS middleware for the FastAPI app
|
57 |
+
cors_allowed_origins_str = os.getenv("CORS_ALLOWED_ORIGINS", "")
|
58 |
+
origins = cors_allowed_origins_str.split(",") if cors_allowed_origins_str else ["*"]
|
59 |
+
app.add_middleware(
|
60 |
+
CORSMiddleware,
|
61 |
+
allow_origins=origins,
|
62 |
+
allow_credentials=True,
|
63 |
+
allow_methods=["*"],
|
64 |
+
allow_headers=["*"],
|
65 |
+
)
|
66 |
+
|
67 |
+
task_dir = utils.task_dir()
|
68 |
+
app.mount(
|
69 |
+
"/tasks", StaticFiles(directory=task_dir, html=True, follow_symlink=True), name=""
|
70 |
+
)
|
71 |
+
|
72 |
+
public_dir = utils.public_dir()
|
73 |
+
app.mount("/", StaticFiles(directory=public_dir, html=True), name="")
|
74 |
+
|
75 |
+
|
76 |
+
@app.on_event("shutdown")
|
77 |
+
def shutdown_event():
|
78 |
+
logger.info("shutdown event")
|
79 |
+
|
80 |
+
|
81 |
+
@app.on_event("startup")
|
82 |
+
def startup_event():
|
83 |
+
logger.info("startup event")
|
84 |
+
|
85 |
+
# 检测FFmpeg硬件加速
|
86 |
+
hwaccel_info = ffmpeg_utils.detect_hardware_acceleration()
|
87 |
+
if hwaccel_info["available"]:
|
88 |
+
logger.info(f"FFmpeg硬件加速检测结果: 可用 | 类型: {hwaccel_info['type']} | 编码器: {hwaccel_info['encoder']} | 独立显卡: {hwaccel_info['is_dedicated_gpu']} | 参数: {hwaccel_info['hwaccel_args']}")
|
89 |
+
else:
|
90 |
+
logger.warning(f"FFmpeg硬件加速不可用: {hwaccel_info['message']}, 将使用CPU软件编码")
|
app/config/__init__.py
ADDED
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import os
|
2 |
+
import sys
|
3 |
+
|
4 |
+
from loguru import logger
|
5 |
+
|
6 |
+
from app.config import config
|
7 |
+
from app.utils import utils
|
8 |
+
|
9 |
+
|
10 |
+
def __init_logger():
|
11 |
+
# _log_file = utils.storage_dir("logs/server.log")
|
12 |
+
_lvl = config.log_level
|
13 |
+
root_dir = os.path.dirname(
|
14 |
+
os.path.dirname(os.path.dirname(os.path.realpath(__file__)))
|
15 |
+
)
|
16 |
+
|
17 |
+
def format_record(record):
|
18 |
+
# 获取日志记录中的文件全路径
|
19 |
+
file_path = record["file"].path
|
20 |
+
# 将绝对路径转换为相对于项目根目录的路径
|
21 |
+
relative_path = os.path.relpath(file_path, root_dir)
|
22 |
+
# 更新记录中的文件路径
|
23 |
+
record["file"].path = f"./{relative_path}"
|
24 |
+
# 返回修改后的格式字符串
|
25 |
+
# 您可以根据需要调整这里的格式
|
26 |
+
_format = (
|
27 |
+
"<green>{time:%Y-%m-%d %H:%M:%S}</> | "
|
28 |
+
+ "<level>{level}</> | "
|
29 |
+
+ '"{file.path}:{line}":<blue> {function}</> '
|
30 |
+
+ "- <level>{message}</>"
|
31 |
+
+ "\n"
|
32 |
+
)
|
33 |
+
return _format
|
34 |
+
|
35 |
+
logger.remove()
|
36 |
+
|
37 |
+
logger.add(
|
38 |
+
sys.stdout,
|
39 |
+
level=_lvl,
|
40 |
+
format=format_record,
|
41 |
+
colorize=True,
|
42 |
+
)
|
43 |
+
|
44 |
+
# logger.add(
|
45 |
+
# _log_file,
|
46 |
+
# level=_lvl,
|
47 |
+
# format=format_record,
|
48 |
+
# rotation="00:00",
|
49 |
+
# retention="3 days",
|
50 |
+
# backtrace=True,
|
51 |
+
# diagnose=True,
|
52 |
+
# enqueue=True,
|
53 |
+
# )
|
54 |
+
|
55 |
+
|
56 |
+
__init_logger()
|
app/config/config.py
ADDED
@@ -0,0 +1,85 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import os
|
2 |
+
import socket
|
3 |
+
import toml
|
4 |
+
import shutil
|
5 |
+
from loguru import logger
|
6 |
+
|
7 |
+
root_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.realpath(__file__))))
|
8 |
+
config_file = f"{root_dir}/config.toml"
|
9 |
+
version_file = f"{root_dir}/project_version"
|
10 |
+
|
11 |
+
|
12 |
+
def get_version_from_file():
|
13 |
+
"""从project_version文件中读取版本号"""
|
14 |
+
try:
|
15 |
+
if os.path.isfile(version_file):
|
16 |
+
with open(version_file, "r", encoding="utf-8") as f:
|
17 |
+
return f.read().strip()
|
18 |
+
return "0.1.0" # 默认版本号
|
19 |
+
except Exception as e:
|
20 |
+
logger.error(f"读取版本号文件失败: {str(e)}")
|
21 |
+
return "0.1.0" # 默认版本号
|
22 |
+
|
23 |
+
|
24 |
+
def load_config():
|
25 |
+
# fix: IsADirectoryError: [Errno 21] Is a directory: '/NarratoAI/config.toml'
|
26 |
+
if os.path.isdir(config_file):
|
27 |
+
shutil.rmtree(config_file)
|
28 |
+
|
29 |
+
if not os.path.isfile(config_file):
|
30 |
+
example_file = f"{root_dir}/config.example.toml"
|
31 |
+
if os.path.isfile(example_file):
|
32 |
+
shutil.copyfile(example_file, config_file)
|
33 |
+
logger.info(f"copy config.example.toml to config.toml")
|
34 |
+
|
35 |
+
logger.info(f"load config from file: {config_file}")
|
36 |
+
|
37 |
+
try:
|
38 |
+
_config_ = toml.load(config_file)
|
39 |
+
except Exception as e:
|
40 |
+
logger.warning(f"load config failed: {str(e)}, try to load as utf-8-sig")
|
41 |
+
with open(config_file, mode="r", encoding="utf-8-sig") as fp:
|
42 |
+
_cfg_content = fp.read()
|
43 |
+
_config_ = toml.loads(_cfg_content)
|
44 |
+
return _config_
|
45 |
+
|
46 |
+
|
47 |
+
def save_config():
|
48 |
+
with open(config_file, "w", encoding="utf-8") as f:
|
49 |
+
_cfg["app"] = app
|
50 |
+
_cfg["azure"] = azure
|
51 |
+
_cfg["ui"] = ui
|
52 |
+
f.write(toml.dumps(_cfg))
|
53 |
+
|
54 |
+
|
55 |
+
_cfg = load_config()
|
56 |
+
app = _cfg.get("app", {})
|
57 |
+
whisper = _cfg.get("whisper", {})
|
58 |
+
proxy = _cfg.get("proxy", {})
|
59 |
+
azure = _cfg.get("azure", {})
|
60 |
+
ui = _cfg.get("ui", {})
|
61 |
+
frames = _cfg.get("frames", {})
|
62 |
+
|
63 |
+
hostname = socket.gethostname()
|
64 |
+
|
65 |
+
log_level = _cfg.get("log_level", "DEBUG")
|
66 |
+
listen_host = _cfg.get("listen_host", "0.0.0.0")
|
67 |
+
listen_port = _cfg.get("listen_port", 8080)
|
68 |
+
project_name = _cfg.get("project_name", "NarratoAI")
|
69 |
+
project_description = _cfg.get(
|
70 |
+
"project_description",
|
71 |
+
"<a href='https://github.com/linyqh/NarratoAI'>https://github.com/linyqh/NarratoAI</a>",
|
72 |
+
)
|
73 |
+
# 从文件读取版本号,而不是从配置文件中获取
|
74 |
+
project_version = get_version_from_file()
|
75 |
+
reload_debug = False
|
76 |
+
|
77 |
+
imagemagick_path = app.get("imagemagick_path", "")
|
78 |
+
if imagemagick_path and os.path.isfile(imagemagick_path):
|
79 |
+
os.environ["IMAGEMAGICK_BINARY"] = imagemagick_path
|
80 |
+
|
81 |
+
ffmpeg_path = app.get("ffmpeg_path", "")
|
82 |
+
if ffmpeg_path and os.path.isfile(ffmpeg_path):
|
83 |
+
os.environ["IMAGEIO_FFMPEG_EXE"] = ffmpeg_path
|
84 |
+
|
85 |
+
logger.info(f"{project_name} v{project_version}")
|
app/controllers/base.py
ADDED
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from uuid import uuid4
|
2 |
+
|
3 |
+
from fastapi import Request
|
4 |
+
|
5 |
+
from app.config import config
|
6 |
+
from app.models.exception import HttpException
|
7 |
+
|
8 |
+
|
9 |
+
def get_task_id(request: Request):
|
10 |
+
task_id = request.headers.get("x-task-id")
|
11 |
+
if not task_id:
|
12 |
+
task_id = uuid4()
|
13 |
+
return str(task_id)
|
14 |
+
|
15 |
+
|
16 |
+
def get_api_key(request: Request):
|
17 |
+
api_key = request.headers.get("x-api-key")
|
18 |
+
return api_key
|
19 |
+
|
20 |
+
|
21 |
+
def verify_token(request: Request):
|
22 |
+
token = get_api_key(request)
|
23 |
+
if token != config.app.get("api_key", ""):
|
24 |
+
request_id = get_task_id(request)
|
25 |
+
request_url = request.url
|
26 |
+
user_agent = request.headers.get("user-agent")
|
27 |
+
raise HttpException(
|
28 |
+
task_id=request_id,
|
29 |
+
status_code=401,
|
30 |
+
message=f"invalid token: {request_url}, {user_agent}",
|
31 |
+
)
|
app/controllers/manager/base_manager.py
ADDED
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import threading
|
2 |
+
from typing import Callable, Any, Dict
|
3 |
+
|
4 |
+
|
5 |
+
class TaskManager:
|
6 |
+
def __init__(self, max_concurrent_tasks: int):
|
7 |
+
self.max_concurrent_tasks = max_concurrent_tasks
|
8 |
+
self.current_tasks = 0
|
9 |
+
self.lock = threading.Lock()
|
10 |
+
self.queue = self.create_queue()
|
11 |
+
|
12 |
+
def create_queue(self):
|
13 |
+
raise NotImplementedError()
|
14 |
+
|
15 |
+
def add_task(self, func: Callable, *args: Any, **kwargs: Any):
|
16 |
+
with self.lock:
|
17 |
+
if self.current_tasks < self.max_concurrent_tasks:
|
18 |
+
print(f"add task: {func.__name__}, current_tasks: {self.current_tasks}")
|
19 |
+
self.execute_task(func, *args, **kwargs)
|
20 |
+
else:
|
21 |
+
print(
|
22 |
+
f"enqueue task: {func.__name__}, current_tasks: {self.current_tasks}"
|
23 |
+
)
|
24 |
+
self.enqueue({"func": func, "args": args, "kwargs": kwargs})
|
25 |
+
|
26 |
+
def execute_task(self, func: Callable, *args: Any, **kwargs: Any):
|
27 |
+
thread = threading.Thread(
|
28 |
+
target=self.run_task, args=(func, *args), kwargs=kwargs
|
29 |
+
)
|
30 |
+
thread.start()
|
31 |
+
|
32 |
+
def run_task(self, func: Callable, *args: Any, **kwargs: Any):
|
33 |
+
try:
|
34 |
+
with self.lock:
|
35 |
+
self.current_tasks += 1
|
36 |
+
func(*args, **kwargs) # 在这里调用函数,传递*args和**kwargs
|
37 |
+
finally:
|
38 |
+
self.task_done()
|
39 |
+
|
40 |
+
def check_queue(self):
|
41 |
+
with self.lock:
|
42 |
+
if (
|
43 |
+
self.current_tasks < self.max_concurrent_tasks
|
44 |
+
and not self.is_queue_empty()
|
45 |
+
):
|
46 |
+
task_info = self.dequeue()
|
47 |
+
func = task_info["func"]
|
48 |
+
args = task_info.get("args", ())
|
49 |
+
kwargs = task_info.get("kwargs", {})
|
50 |
+
self.execute_task(func, *args, **kwargs)
|
51 |
+
|
52 |
+
def task_done(self):
|
53 |
+
with self.lock:
|
54 |
+
self.current_tasks -= 1
|
55 |
+
self.check_queue()
|
56 |
+
|
57 |
+
def enqueue(self, task: Dict):
|
58 |
+
raise NotImplementedError()
|
59 |
+
|
60 |
+
def dequeue(self):
|
61 |
+
raise NotImplementedError()
|
62 |
+
|
63 |
+
def is_queue_empty(self):
|
64 |
+
raise NotImplementedError()
|
app/controllers/manager/memory_manager.py
ADDED
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from queue import Queue
|
2 |
+
from typing import Dict
|
3 |
+
|
4 |
+
from app.controllers.manager.base_manager import TaskManager
|
5 |
+
|
6 |
+
|
7 |
+
class InMemoryTaskManager(TaskManager):
|
8 |
+
def create_queue(self):
|
9 |
+
return Queue()
|
10 |
+
|
11 |
+
def enqueue(self, task: Dict):
|
12 |
+
self.queue.put(task)
|
13 |
+
|
14 |
+
def dequeue(self):
|
15 |
+
return self.queue.get()
|
16 |
+
|
17 |
+
def is_queue_empty(self):
|
18 |
+
return self.queue.empty()
|
app/controllers/manager/redis_manager.py
ADDED
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import json
|
2 |
+
from typing import Dict
|
3 |
+
|
4 |
+
import redis
|
5 |
+
|
6 |
+
from app.controllers.manager.base_manager import TaskManager
|
7 |
+
from app.models.schema import VideoParams
|
8 |
+
from app.services import task as tm
|
9 |
+
|
10 |
+
FUNC_MAP = {
|
11 |
+
"start": tm.start,
|
12 |
+
# 'start_test': tm.start_test
|
13 |
+
}
|
14 |
+
|
15 |
+
|
16 |
+
class RedisTaskManager(TaskManager):
|
17 |
+
def __init__(self, max_concurrent_tasks: int, redis_url: str):
|
18 |
+
self.redis_client = redis.Redis.from_url(redis_url)
|
19 |
+
super().__init__(max_concurrent_tasks)
|
20 |
+
|
21 |
+
def create_queue(self):
|
22 |
+
return "task_queue"
|
23 |
+
|
24 |
+
def enqueue(self, task: Dict):
|
25 |
+
task_with_serializable_params = task.copy()
|
26 |
+
|
27 |
+
if "params" in task["kwargs"] and isinstance(
|
28 |
+
task["kwargs"]["params"], VideoParams
|
29 |
+
):
|
30 |
+
task_with_serializable_params["kwargs"]["params"] = task["kwargs"][
|
31 |
+
"params"
|
32 |
+
].dict()
|
33 |
+
|
34 |
+
# 将函数对象转换为其名称
|
35 |
+
task_with_serializable_params["func"] = task["func"].__name__
|
36 |
+
self.redis_client.rpush(self.queue, json.dumps(task_with_serializable_params))
|
37 |
+
|
38 |
+
def dequeue(self):
|
39 |
+
task_json = self.redis_client.lpop(self.queue)
|
40 |
+
if task_json:
|
41 |
+
task_info = json.loads(task_json)
|
42 |
+
# 将函数名称转换回函数对象
|
43 |
+
task_info["func"] = FUNC_MAP[task_info["func"]]
|
44 |
+
|
45 |
+
if "params" in task_info["kwargs"] and isinstance(
|
46 |
+
task_info["kwargs"]["params"], dict
|
47 |
+
):
|
48 |
+
task_info["kwargs"]["params"] = VideoParams(
|
49 |
+
**task_info["kwargs"]["params"]
|
50 |
+
)
|
51 |
+
|
52 |
+
return task_info
|
53 |
+
return None
|
54 |
+
|
55 |
+
def is_queue_empty(self):
|
56 |
+
return self.redis_client.llen(self.queue) == 0
|
app/controllers/ping.py
ADDED
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from fastapi import APIRouter
|
2 |
+
from fastapi import Request
|
3 |
+
|
4 |
+
router = APIRouter()
|
5 |
+
|
6 |
+
|
7 |
+
@router.get(
|
8 |
+
"/ping",
|
9 |
+
tags=["Health Check"],
|
10 |
+
description="检查服务可用性",
|
11 |
+
response_description="pong",
|
12 |
+
)
|
13 |
+
def ping(request: Request) -> str:
|
14 |
+
return "pong"
|
app/controllers/v1/base.py
ADDED
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from fastapi import APIRouter, Depends
|
2 |
+
|
3 |
+
|
4 |
+
def new_router(dependencies=None):
|
5 |
+
router = APIRouter()
|
6 |
+
router.tags = ["V1"]
|
7 |
+
router.prefix = "/api/v1"
|
8 |
+
# 将认证依赖项应用于所有路由
|
9 |
+
if dependencies:
|
10 |
+
router.dependencies = dependencies
|
11 |
+
return router
|
app/controllers/v1/llm.py
ADDED
@@ -0,0 +1,93 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from fastapi import Request, File, UploadFile
|
2 |
+
import os
|
3 |
+
from app.controllers.v1.base import new_router
|
4 |
+
from app.models.schema import (
|
5 |
+
VideoScriptResponse,
|
6 |
+
VideoScriptRequest,
|
7 |
+
VideoTermsResponse,
|
8 |
+
VideoTermsRequest,
|
9 |
+
VideoTranscriptionRequest,
|
10 |
+
VideoTranscriptionResponse,
|
11 |
+
)
|
12 |
+
from app.services import llm
|
13 |
+
from app.utils import utils
|
14 |
+
from app.config import config
|
15 |
+
|
16 |
+
# 认证依赖项
|
17 |
+
# router = new_router(dependencies=[Depends(base.verify_token)])
|
18 |
+
router = new_router()
|
19 |
+
|
20 |
+
# 定义上传目录
|
21 |
+
UPLOAD_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "uploads")
|
22 |
+
|
23 |
+
@router.post(
|
24 |
+
"/scripts",
|
25 |
+
response_model=VideoScriptResponse,
|
26 |
+
summary="Create a script for the video",
|
27 |
+
)
|
28 |
+
def generate_video_script(request: Request, body: VideoScriptRequest):
|
29 |
+
video_script = llm.generate_script(
|
30 |
+
video_subject=body.video_subject,
|
31 |
+
language=body.video_language,
|
32 |
+
paragraph_number=body.paragraph_number,
|
33 |
+
)
|
34 |
+
response = {"video_script": video_script}
|
35 |
+
return utils.get_response(200, response)
|
36 |
+
|
37 |
+
|
38 |
+
@router.post(
|
39 |
+
"/terms",
|
40 |
+
response_model=VideoTermsResponse,
|
41 |
+
summary="Generate video terms based on the video script",
|
42 |
+
)
|
43 |
+
def generate_video_terms(request: Request, body: VideoTermsRequest):
|
44 |
+
video_terms = llm.generate_terms(
|
45 |
+
video_subject=body.video_subject,
|
46 |
+
video_script=body.video_script,
|
47 |
+
amount=body.amount,
|
48 |
+
)
|
49 |
+
response = {"video_terms": video_terms}
|
50 |
+
return utils.get_response(200, response)
|
51 |
+
|
52 |
+
|
53 |
+
@router.post(
|
54 |
+
"/transcription",
|
55 |
+
response_model=VideoTranscriptionResponse,
|
56 |
+
summary="Transcribe video content using Gemini"
|
57 |
+
)
|
58 |
+
async def transcribe_video(
|
59 |
+
request: Request,
|
60 |
+
video_name: str,
|
61 |
+
language: str = "zh-CN",
|
62 |
+
video_file: UploadFile = File(...)
|
63 |
+
):
|
64 |
+
"""
|
65 |
+
使用 Gemini 转录视频内容,包括时间戳、画面描述和语音内容
|
66 |
+
|
67 |
+
Args:
|
68 |
+
video_name: 视频名称
|
69 |
+
language: 语言代码,默认zh-CN
|
70 |
+
video_file: 上传的视频文件
|
71 |
+
"""
|
72 |
+
# 创建临时目录用于存储上传的视频
|
73 |
+
os.makedirs(UPLOAD_DIR, exist_ok=True)
|
74 |
+
|
75 |
+
# 保存上传的视频文件
|
76 |
+
video_path = os.path.join(UPLOAD_DIR, video_file.filename)
|
77 |
+
with open(video_path, "wb") as buffer:
|
78 |
+
content = await video_file.read()
|
79 |
+
buffer.write(content)
|
80 |
+
|
81 |
+
try:
|
82 |
+
transcription = llm.gemini_video_transcription(
|
83 |
+
video_name=video_name,
|
84 |
+
video_path=video_path,
|
85 |
+
language=language,
|
86 |
+
llm_provider_video=config.app.get("video_llm_provider", "gemini")
|
87 |
+
)
|
88 |
+
response = {"transcription": transcription}
|
89 |
+
return utils.get_response(200, response)
|
90 |
+
finally:
|
91 |
+
# 处理完成后删除临时文件
|
92 |
+
if os.path.exists(video_path):
|
93 |
+
os.remove(video_path)
|
app/controllers/v1/video.py
ADDED
@@ -0,0 +1,271 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import glob
|
2 |
+
import os
|
3 |
+
import pathlib
|
4 |
+
import shutil
|
5 |
+
from typing import Union
|
6 |
+
|
7 |
+
from fastapi import BackgroundTasks, Depends, Path, Request, UploadFile
|
8 |
+
from fastapi.params import File
|
9 |
+
from fastapi.responses import FileResponse, StreamingResponse
|
10 |
+
from loguru import logger
|
11 |
+
|
12 |
+
from app.config import config
|
13 |
+
from app.controllers import base
|
14 |
+
from app.controllers.manager.memory_manager import InMemoryTaskManager
|
15 |
+
from app.controllers.manager.redis_manager import RedisTaskManager
|
16 |
+
from app.controllers.v1.base import new_router
|
17 |
+
from app.models.exception import HttpException
|
18 |
+
from app.models.schema import (
|
19 |
+
AudioRequest,
|
20 |
+
BgmRetrieveResponse,
|
21 |
+
BgmUploadResponse,
|
22 |
+
SubtitleRequest,
|
23 |
+
TaskDeletionResponse,
|
24 |
+
TaskQueryRequest,
|
25 |
+
TaskQueryResponse,
|
26 |
+
TaskResponse,
|
27 |
+
TaskVideoRequest,
|
28 |
+
)
|
29 |
+
from app.services import state as sm
|
30 |
+
from app.services import task as tm
|
31 |
+
from app.utils import utils
|
32 |
+
|
33 |
+
# 认证依赖项
|
34 |
+
# router = new_router(dependencies=[Depends(base.verify_token)])
|
35 |
+
router = new_router()
|
36 |
+
|
37 |
+
_enable_redis = config.app.get("enable_redis", False)
|
38 |
+
_redis_host = config.app.get("redis_host", "localhost")
|
39 |
+
_redis_port = config.app.get("redis_port", 6379)
|
40 |
+
_redis_db = config.app.get("redis_db", 0)
|
41 |
+
_redis_password = config.app.get("redis_password", None)
|
42 |
+
_max_concurrent_tasks = config.app.get("max_concurrent_tasks", 5)
|
43 |
+
|
44 |
+
redis_url = f"redis://:{_redis_password}@{_redis_host}:{_redis_port}/{_redis_db}"
|
45 |
+
# 根据配置选择合适的任务管理器
|
46 |
+
if _enable_redis:
|
47 |
+
task_manager = RedisTaskManager(
|
48 |
+
max_concurrent_tasks=_max_concurrent_tasks, redis_url=redis_url
|
49 |
+
)
|
50 |
+
else:
|
51 |
+
task_manager = InMemoryTaskManager(max_concurrent_tasks=_max_concurrent_tasks)
|
52 |
+
|
53 |
+
|
54 |
+
@router.post("/videos", response_model=TaskResponse, summary="Generate a short video")
|
55 |
+
def create_video(
|
56 |
+
background_tasks: BackgroundTasks, request: Request, body: TaskVideoRequest
|
57 |
+
):
|
58 |
+
return create_task(request, body, stop_at="video")
|
59 |
+
|
60 |
+
|
61 |
+
@router.post("/subtitle", response_model=TaskResponse, summary="Generate subtitle only")
|
62 |
+
def create_subtitle(
|
63 |
+
background_tasks: BackgroundTasks, request: Request, body: SubtitleRequest
|
64 |
+
):
|
65 |
+
return create_task(request, body, stop_at="subtitle")
|
66 |
+
|
67 |
+
|
68 |
+
@router.post("/audio", response_model=TaskResponse, summary="Generate audio only")
|
69 |
+
def create_audio(
|
70 |
+
background_tasks: BackgroundTasks, request: Request, body: AudioRequest
|
71 |
+
):
|
72 |
+
return create_task(request, body, stop_at="audio")
|
73 |
+
|
74 |
+
|
75 |
+
def create_task(
|
76 |
+
request: Request,
|
77 |
+
body: Union[TaskVideoRequest, SubtitleRequest, AudioRequest],
|
78 |
+
stop_at: str,
|
79 |
+
):
|
80 |
+
task_id = utils.get_uuid()
|
81 |
+
request_id = base.get_task_id(request)
|
82 |
+
try:
|
83 |
+
task = {
|
84 |
+
"task_id": task_id,
|
85 |
+
"request_id": request_id,
|
86 |
+
"params": body.model_dump(),
|
87 |
+
}
|
88 |
+
sm.state.update_task(task_id)
|
89 |
+
task_manager.add_task(tm.start, task_id=task_id, params=body, stop_at=stop_at)
|
90 |
+
logger.success(f"Task created: {utils.to_json(task)}")
|
91 |
+
return utils.get_response(200, task)
|
92 |
+
except ValueError as e:
|
93 |
+
raise HttpException(
|
94 |
+
task_id=task_id, status_code=400, message=f"{request_id}: {str(e)}"
|
95 |
+
)
|
96 |
+
|
97 |
+
|
98 |
+
@router.get(
|
99 |
+
"/tasks/{task_id}", response_model=TaskQueryResponse, summary="Query task status"
|
100 |
+
)
|
101 |
+
def get_task(
|
102 |
+
request: Request,
|
103 |
+
task_id: str = Path(..., description="Task ID"),
|
104 |
+
query: TaskQueryRequest = Depends(),
|
105 |
+
):
|
106 |
+
endpoint = config.app.get("endpoint", "")
|
107 |
+
if not endpoint:
|
108 |
+
endpoint = str(request.base_url)
|
109 |
+
endpoint = endpoint.rstrip("/")
|
110 |
+
|
111 |
+
request_id = base.get_task_id(request)
|
112 |
+
task = sm.state.get_task(task_id)
|
113 |
+
if task:
|
114 |
+
task_dir = utils.task_dir()
|
115 |
+
|
116 |
+
def file_to_uri(file):
|
117 |
+
if not file.startswith(endpoint):
|
118 |
+
_uri_path = v.replace(task_dir, "tasks").replace("\\", "/")
|
119 |
+
_uri_path = f"{endpoint}/{_uri_path}"
|
120 |
+
else:
|
121 |
+
_uri_path = file
|
122 |
+
return _uri_path
|
123 |
+
|
124 |
+
if "videos" in task:
|
125 |
+
videos = task["videos"]
|
126 |
+
urls = []
|
127 |
+
for v in videos:
|
128 |
+
urls.append(file_to_uri(v))
|
129 |
+
task["videos"] = urls
|
130 |
+
if "combined_videos" in task:
|
131 |
+
combined_videos = task["combined_videos"]
|
132 |
+
urls = []
|
133 |
+
for v in combined_videos:
|
134 |
+
urls.append(file_to_uri(v))
|
135 |
+
task["combined_videos"] = urls
|
136 |
+
return utils.get_response(200, task)
|
137 |
+
|
138 |
+
raise HttpException(
|
139 |
+
task_id=task_id, status_code=404, message=f"{request_id}: task not found"
|
140 |
+
)
|
141 |
+
|
142 |
+
|
143 |
+
@router.delete(
|
144 |
+
"/tasks/{task_id}",
|
145 |
+
response_model=TaskDeletionResponse,
|
146 |
+
summary="Delete a generated short video task",
|
147 |
+
)
|
148 |
+
def delete_video(request: Request, task_id: str = Path(..., description="Task ID")):
|
149 |
+
request_id = base.get_task_id(request)
|
150 |
+
task = sm.state.get_task(task_id)
|
151 |
+
if task:
|
152 |
+
tasks_dir = utils.task_dir()
|
153 |
+
current_task_dir = os.path.join(tasks_dir, task_id)
|
154 |
+
if os.path.exists(current_task_dir):
|
155 |
+
shutil.rmtree(current_task_dir)
|
156 |
+
|
157 |
+
sm.state.delete_task(task_id)
|
158 |
+
logger.success(f"video deleted: {utils.to_json(task)}")
|
159 |
+
return utils.get_response(200)
|
160 |
+
|
161 |
+
raise HttpException(
|
162 |
+
task_id=task_id, status_code=404, message=f"{request_id}: task not found"
|
163 |
+
)
|
164 |
+
|
165 |
+
|
166 |
+
# @router.get(
|
167 |
+
# "/musics", response_model=BgmRetrieveResponse, summary="Retrieve local BGM files"
|
168 |
+
# )
|
169 |
+
# def get_bgm_list(request: Request):
|
170 |
+
# suffix = "*.mp3"
|
171 |
+
# song_dir = utils.song_dir()
|
172 |
+
# files = glob.glob(os.path.join(song_dir, suffix))
|
173 |
+
# bgm_list = []
|
174 |
+
# for file in files:
|
175 |
+
# bgm_list.append(
|
176 |
+
# {
|
177 |
+
# "name": os.path.basename(file),
|
178 |
+
# "size": os.path.getsize(file),
|
179 |
+
# "file": file,
|
180 |
+
# }
|
181 |
+
# )
|
182 |
+
# response = {"files": bgm_list}
|
183 |
+
# return utils.get_response(200, response)
|
184 |
+
#
|
185 |
+
|
186 |
+
# @router.post(
|
187 |
+
# "/musics",
|
188 |
+
# response_model=BgmUploadResponse,
|
189 |
+
# summary="Upload the BGM file to the songs directory",
|
190 |
+
# )
|
191 |
+
# def upload_bgm_file(request: Request, file: UploadFile = File(...)):
|
192 |
+
# request_id = base.get_task_id(request)
|
193 |
+
# # check file ext
|
194 |
+
# if file.filename.endswith("mp3"):
|
195 |
+
# song_dir = utils.song_dir()
|
196 |
+
# save_path = os.path.join(song_dir, file.filename)
|
197 |
+
# # save file
|
198 |
+
# with open(save_path, "wb+") as buffer:
|
199 |
+
# # If the file already exists, it will be overwritten
|
200 |
+
# file.file.seek(0)
|
201 |
+
# buffer.write(file.file.read())
|
202 |
+
# response = {"file": save_path}
|
203 |
+
# return utils.get_response(200, response)
|
204 |
+
#
|
205 |
+
# raise HttpException(
|
206 |
+
# "", status_code=400, message=f"{request_id}: Only *.mp3 files can be uploaded"
|
207 |
+
# )
|
208 |
+
#
|
209 |
+
#
|
210 |
+
# @router.get("/stream/{file_path:path}")
|
211 |
+
# async def stream_video(request: Request, file_path: str):
|
212 |
+
# tasks_dir = utils.task_dir()
|
213 |
+
# video_path = os.path.join(tasks_dir, file_path)
|
214 |
+
# range_header = request.headers.get("Range")
|
215 |
+
# video_size = os.path.getsize(video_path)
|
216 |
+
# start, end = 0, video_size - 1
|
217 |
+
#
|
218 |
+
# length = video_size
|
219 |
+
# if range_header:
|
220 |
+
# range_ = range_header.split("bytes=")[1]
|
221 |
+
# start, end = [int(part) if part else None for part in range_.split("-")]
|
222 |
+
# if start is None:
|
223 |
+
# start = video_size - end
|
224 |
+
# end = video_size - 1
|
225 |
+
# if end is None:
|
226 |
+
# end = video_size - 1
|
227 |
+
# length = end - start + 1
|
228 |
+
#
|
229 |
+
# def file_iterator(file_path, offset=0, bytes_to_read=None):
|
230 |
+
# with open(file_path, "rb") as f:
|
231 |
+
# f.seek(offset, os.SEEK_SET)
|
232 |
+
# remaining = bytes_to_read or video_size
|
233 |
+
# while remaining > 0:
|
234 |
+
# bytes_to_read = min(4096, remaining)
|
235 |
+
# data = f.read(bytes_to_read)
|
236 |
+
# if not data:
|
237 |
+
# break
|
238 |
+
# remaining -= len(data)
|
239 |
+
# yield data
|
240 |
+
#
|
241 |
+
# response = StreamingResponse(
|
242 |
+
# file_iterator(video_path, start, length), media_type="video/mp4"
|
243 |
+
# )
|
244 |
+
# response.headers["Content-Range"] = f"bytes {start}-{end}/{video_size}"
|
245 |
+
# response.headers["Accept-Ranges"] = "bytes"
|
246 |
+
# response.headers["Content-Length"] = str(length)
|
247 |
+
# response.status_code = 206 # Partial Content
|
248 |
+
#
|
249 |
+
# return response
|
250 |
+
#
|
251 |
+
#
|
252 |
+
# @router.get("/download/{file_path:path}")
|
253 |
+
# async def download_video(_: Request, file_path: str):
|
254 |
+
# """
|
255 |
+
# download video
|
256 |
+
# :param _: Request request
|
257 |
+
# :param file_path: video file path, eg: /cd1727ed-3473-42a2-a7da-4faafafec72b/final-1.mp4
|
258 |
+
# :return: video file
|
259 |
+
# """
|
260 |
+
# tasks_dir = utils.task_dir()
|
261 |
+
# video_path = os.path.join(tasks_dir, file_path)
|
262 |
+
# file_path = pathlib.Path(video_path)
|
263 |
+
# filename = file_path.stem
|
264 |
+
# extension = file_path.suffix
|
265 |
+
# headers = {"Content-Disposition": f"attachment; filename={filename}{extension}"}
|
266 |
+
# return FileResponse(
|
267 |
+
# path=video_path,
|
268 |
+
# headers=headers,
|
269 |
+
# filename=f"{filename}{extension}",
|
270 |
+
# media_type=f"video/{extension[1:]}",
|
271 |
+
# )
|
app/controllers/v2/base.py
ADDED
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from fastapi import APIRouter, Depends
|
2 |
+
|
3 |
+
|
4 |
+
def v2_router(dependencies=None):
|
5 |
+
router = APIRouter()
|
6 |
+
router.tags = ["V2"]
|
7 |
+
router.prefix = "/api/v2"
|
8 |
+
# 将认证依赖项应用于所有路由
|
9 |
+
if dependencies:
|
10 |
+
router.dependencies = dependencies
|
11 |
+
return router
|
app/controllers/v2/script.py
ADDED
@@ -0,0 +1,170 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from fastapi import APIRouter, BackgroundTasks
|
2 |
+
from loguru import logger
|
3 |
+
import os
|
4 |
+
|
5 |
+
from app.models.schema_v2 import (
|
6 |
+
GenerateScriptRequest,
|
7 |
+
GenerateScriptResponse,
|
8 |
+
CropVideoRequest,
|
9 |
+
CropVideoResponse,
|
10 |
+
DownloadVideoRequest,
|
11 |
+
DownloadVideoResponse,
|
12 |
+
StartSubclipRequest,
|
13 |
+
StartSubclipResponse
|
14 |
+
)
|
15 |
+
from app.models.schema import VideoClipParams
|
16 |
+
from app.services.script_service import ScriptGenerator
|
17 |
+
from app.services.video_service import VideoService
|
18 |
+
from app.utils import utils
|
19 |
+
from app.controllers.v2.base import v2_router
|
20 |
+
from app.models.schema import VideoClipParams
|
21 |
+
from app.services.youtube_service import YoutubeService
|
22 |
+
from app.services import task as task_service
|
23 |
+
|
24 |
+
router = v2_router()
|
25 |
+
|
26 |
+
|
27 |
+
@router.post(
|
28 |
+
"/scripts/generate",
|
29 |
+
response_model=GenerateScriptResponse,
|
30 |
+
summary="同步请求;生成视频脚本 (V2)"
|
31 |
+
)
|
32 |
+
async def generate_script(
|
33 |
+
request: GenerateScriptRequest,
|
34 |
+
background_tasks: BackgroundTasks
|
35 |
+
):
|
36 |
+
"""
|
37 |
+
生成视频脚本的V2版本API
|
38 |
+
"""
|
39 |
+
task_id = utils.get_uuid()
|
40 |
+
|
41 |
+
try:
|
42 |
+
generator = ScriptGenerator()
|
43 |
+
script = await generator.generate_script(
|
44 |
+
video_path=request.video_path,
|
45 |
+
video_theme=request.video_theme,
|
46 |
+
custom_prompt=request.custom_prompt,
|
47 |
+
skip_seconds=request.skip_seconds,
|
48 |
+
threshold=request.threshold,
|
49 |
+
vision_batch_size=request.vision_batch_size,
|
50 |
+
vision_llm_provider=request.vision_llm_provider
|
51 |
+
)
|
52 |
+
|
53 |
+
return {
|
54 |
+
"task_id": task_id,
|
55 |
+
"script": script
|
56 |
+
}
|
57 |
+
|
58 |
+
except Exception as e:
|
59 |
+
logger.exception(f"Generate script failed: {str(e)}")
|
60 |
+
raise
|
61 |
+
|
62 |
+
|
63 |
+
@router.post(
|
64 |
+
"/scripts/crop",
|
65 |
+
response_model=CropVideoResponse,
|
66 |
+
summary="同步请求;裁剪视频 (V2)"
|
67 |
+
)
|
68 |
+
async def crop_video(
|
69 |
+
request: CropVideoRequest,
|
70 |
+
background_tasks: BackgroundTasks
|
71 |
+
):
|
72 |
+
"""
|
73 |
+
根据脚本裁剪视频的V2版本API
|
74 |
+
"""
|
75 |
+
try:
|
76 |
+
# 调用视频裁剪服务
|
77 |
+
video_service = VideoService()
|
78 |
+
task_id, subclip_videos = await video_service.crop_video(
|
79 |
+
video_path=request.video_origin_path,
|
80 |
+
video_script=request.video_script
|
81 |
+
)
|
82 |
+
logger.debug(f"裁剪视频成功,视频片段路径: {subclip_videos}")
|
83 |
+
logger.debug(type(subclip_videos))
|
84 |
+
return {
|
85 |
+
"task_id": task_id,
|
86 |
+
"subclip_videos": subclip_videos
|
87 |
+
}
|
88 |
+
|
89 |
+
except Exception as e:
|
90 |
+
logger.exception(f"Crop video failed: {str(e)}")
|
91 |
+
raise
|
92 |
+
|
93 |
+
|
94 |
+
@router.post(
|
95 |
+
"/youtube/download",
|
96 |
+
response_model=DownloadVideoResponse,
|
97 |
+
summary="同步请求;下载YouTube视频 (V2)"
|
98 |
+
)
|
99 |
+
async def download_youtube_video(
|
100 |
+
request: DownloadVideoRequest,
|
101 |
+
background_tasks: BackgroundTasks
|
102 |
+
):
|
103 |
+
"""
|
104 |
+
下载指定分辨率的YouTube视频
|
105 |
+
"""
|
106 |
+
try:
|
107 |
+
youtube_service = YoutubeService()
|
108 |
+
task_id, output_path, filename = await youtube_service.download_video(
|
109 |
+
url=request.url,
|
110 |
+
resolution=request.resolution,
|
111 |
+
output_format=request.output_format,
|
112 |
+
rename=request.rename
|
113 |
+
)
|
114 |
+
|
115 |
+
return {
|
116 |
+
"task_id": task_id,
|
117 |
+
"output_path": output_path,
|
118 |
+
"resolution": request.resolution,
|
119 |
+
"format": request.output_format,
|
120 |
+
"filename": filename
|
121 |
+
}
|
122 |
+
|
123 |
+
except Exception as e:
|
124 |
+
logger.exception(f"Download YouTube video failed: {str(e)}")
|
125 |
+
raise
|
126 |
+
|
127 |
+
|
128 |
+
@router.post(
|
129 |
+
"/scripts/start-subclip",
|
130 |
+
response_model=StartSubclipResponse,
|
131 |
+
summary="异步请求;开始视频剪辑任务 (V2)"
|
132 |
+
)
|
133 |
+
async def start_subclip(
|
134 |
+
request: VideoClipParams,
|
135 |
+
task_id: str,
|
136 |
+
subclip_videos: dict,
|
137 |
+
background_tasks: BackgroundTasks
|
138 |
+
):
|
139 |
+
"""
|
140 |
+
开始视频剪辑任务的V2版本API
|
141 |
+
"""
|
142 |
+
try:
|
143 |
+
# 构建参数对象
|
144 |
+
params = VideoClipParams(
|
145 |
+
video_origin_path=request.video_origin_path,
|
146 |
+
video_clip_json_path=request.video_clip_json_path,
|
147 |
+
voice_name=request.voice_name,
|
148 |
+
voice_rate=request.voice_rate,
|
149 |
+
voice_pitch=request.voice_pitch,
|
150 |
+
subtitle_enabled=request.subtitle_enabled,
|
151 |
+
video_aspect=request.video_aspect,
|
152 |
+
n_threads=request.n_threads
|
153 |
+
)
|
154 |
+
|
155 |
+
# 在后台任务中执行视频剪辑
|
156 |
+
background_tasks.add_task(
|
157 |
+
task_service.start_subclip,
|
158 |
+
task_id=task_id,
|
159 |
+
params=params,
|
160 |
+
subclip_path_videos=subclip_videos
|
161 |
+
)
|
162 |
+
|
163 |
+
return {
|
164 |
+
"task_id": task_id,
|
165 |
+
"state": "PROCESSING" # 初始状态
|
166 |
+
}
|
167 |
+
|
168 |
+
except Exception as e:
|
169 |
+
logger.exception(f"Start subclip task failed: {str(e)}")
|
170 |
+
raise
|
app/models/__init__.py
ADDED
File without changes
|
app/models/const.py
ADDED
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
PUNCTUATIONS = [
|
2 |
+
"?",
|
3 |
+
",",
|
4 |
+
".",
|
5 |
+
"、",
|
6 |
+
";",
|
7 |
+
":",
|
8 |
+
"!",
|
9 |
+
"…",
|
10 |
+
"?",
|
11 |
+
",",
|
12 |
+
"。",
|
13 |
+
"、",
|
14 |
+
";",
|
15 |
+
":",
|
16 |
+
"!",
|
17 |
+
"...",
|
18 |
+
]
|
19 |
+
|
20 |
+
TASK_STATE_FAILED = -1
|
21 |
+
TASK_STATE_COMPLETE = 1
|
22 |
+
TASK_STATE_PROCESSING = 4
|
23 |
+
|
24 |
+
FILE_TYPE_VIDEOS = ["mp4", "mov", "mkv", "webm"]
|
25 |
+
FILE_TYPE_IMAGES = ["jpg", "jpeg", "png", "bmp"]
|
app/models/exception.py
ADDED
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import traceback
|
2 |
+
from typing import Any
|
3 |
+
|
4 |
+
from loguru import logger
|
5 |
+
|
6 |
+
|
7 |
+
class HttpException(Exception):
|
8 |
+
def __init__(
|
9 |
+
self, task_id: str, status_code: int, message: str = "", data: Any = None
|
10 |
+
):
|
11 |
+
self.message = message
|
12 |
+
self.status_code = status_code
|
13 |
+
self.data = data
|
14 |
+
# 获取异常堆栈信息
|
15 |
+
tb_str = traceback.format_exc().strip()
|
16 |
+
if not tb_str or tb_str == "NoneType: None":
|
17 |
+
msg = f"HttpException: {status_code}, {task_id}, {message}"
|
18 |
+
else:
|
19 |
+
msg = f"HttpException: {status_code}, {task_id}, {message}\n{tb_str}"
|
20 |
+
|
21 |
+
if status_code == 400:
|
22 |
+
logger.warning(msg)
|
23 |
+
else:
|
24 |
+
logger.error(msg)
|
25 |
+
|
26 |
+
|
27 |
+
class FileNotFoundException(Exception):
|
28 |
+
pass
|
app/models/schema.py
ADDED
@@ -0,0 +1,391 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import warnings
|
2 |
+
from enum import Enum
|
3 |
+
from typing import Any, List, Optional
|
4 |
+
|
5 |
+
import pydantic
|
6 |
+
from pydantic import BaseModel, Field
|
7 |
+
|
8 |
+
# 忽略 Pydantic 的特定警告
|
9 |
+
warnings.filterwarnings(
|
10 |
+
"ignore",
|
11 |
+
category=UserWarning,
|
12 |
+
message="Field name.*shadows an attribute in parent.*",
|
13 |
+
)
|
14 |
+
|
15 |
+
|
16 |
+
class VideoConcatMode(str, Enum):
|
17 |
+
random = "random"
|
18 |
+
sequential = "sequential"
|
19 |
+
|
20 |
+
|
21 |
+
class VideoAspect(str, Enum):
|
22 |
+
landscape = "16:9"
|
23 |
+
landscape_2 = "4:3"
|
24 |
+
portrait = "9:16"
|
25 |
+
portrait_2 = "3:4"
|
26 |
+
square = "1:1"
|
27 |
+
|
28 |
+
def to_resolution(self):
|
29 |
+
if self == VideoAspect.landscape.value:
|
30 |
+
return 1920, 1080
|
31 |
+
elif self == VideoAspect.portrait.value:
|
32 |
+
return 1080, 1920
|
33 |
+
elif self == VideoAspect.square.value:
|
34 |
+
return 1080, 1080
|
35 |
+
return 1080, 1920
|
36 |
+
|
37 |
+
|
38 |
+
class _Config:
|
39 |
+
arbitrary_types_allowed = True
|
40 |
+
|
41 |
+
|
42 |
+
@pydantic.dataclasses.dataclass(config=_Config)
|
43 |
+
class MaterialInfo:
|
44 |
+
provider: str = "pexels"
|
45 |
+
url: str = ""
|
46 |
+
duration: int = 0
|
47 |
+
|
48 |
+
|
49 |
+
# VoiceNames = [
|
50 |
+
# # zh-CN
|
51 |
+
# "female-zh-CN-XiaoxiaoNeural",
|
52 |
+
# "female-zh-CN-XiaoyiNeural",
|
53 |
+
# "female-zh-CN-liaoning-XiaobeiNeural",
|
54 |
+
# "female-zh-CN-shaanxi-XiaoniNeural",
|
55 |
+
#
|
56 |
+
# "male-zh-CN-YunjianNeural",
|
57 |
+
# "male-zh-CN-YunxiNeural",
|
58 |
+
# "male-zh-CN-YunxiaNeural",
|
59 |
+
# "male-zh-CN-YunyangNeural",
|
60 |
+
#
|
61 |
+
# # "female-zh-HK-HiuGaaiNeural",
|
62 |
+
# # "female-zh-HK-HiuMaanNeural",
|
63 |
+
# # "male-zh-HK-WanLungNeural",
|
64 |
+
# #
|
65 |
+
# # "female-zh-TW-HsiaoChenNeural",
|
66 |
+
# # "female-zh-TW-HsiaoYuNeural",
|
67 |
+
# # "male-zh-TW-YunJheNeural",
|
68 |
+
#
|
69 |
+
# # en-US
|
70 |
+
# "female-en-US-AnaNeural",
|
71 |
+
# "female-en-US-AriaNeural",
|
72 |
+
# "female-en-US-AvaNeural",
|
73 |
+
# "female-en-US-EmmaNeural",
|
74 |
+
# "female-en-US-JennyNeural",
|
75 |
+
# "female-en-US-MichelleNeural",
|
76 |
+
#
|
77 |
+
# "male-en-US-AndrewNeural",
|
78 |
+
# "male-en-US-BrianNeural",
|
79 |
+
# "male-en-US-ChristopherNeural",
|
80 |
+
# "male-en-US-EricNeural",
|
81 |
+
# "male-en-US-GuyNeural",
|
82 |
+
# "male-en-US-RogerNeural",
|
83 |
+
# "male-en-US-SteffanNeural",
|
84 |
+
# ]
|
85 |
+
|
86 |
+
|
87 |
+
class VideoParams(BaseModel):
|
88 |
+
"""
|
89 |
+
{
|
90 |
+
"video_subject": "",
|
91 |
+
"video_aspect": "横屏 16:9(西瓜视频)",
|
92 |
+
"voice_name": "女生-晓晓",
|
93 |
+
"bgm_name": "random",
|
94 |
+
"font_name": "STHeitiMedium 黑体-中",
|
95 |
+
"text_color": "#FFFFFF",
|
96 |
+
"font_size": 60,
|
97 |
+
"stroke_color": "#000000",
|
98 |
+
"stroke_width": 1.5
|
99 |
+
}
|
100 |
+
"""
|
101 |
+
|
102 |
+
video_subject: str
|
103 |
+
video_script: str = "" # 用于生成视频的脚本
|
104 |
+
video_terms: Optional[str | list] = None # 用于生成视频的关键词
|
105 |
+
video_aspect: Optional[VideoAspect] = VideoAspect.portrait.value
|
106 |
+
video_concat_mode: Optional[VideoConcatMode] = VideoConcatMode.random.value
|
107 |
+
video_clip_duration: Optional[int] = 5
|
108 |
+
video_count: Optional[int] = 1
|
109 |
+
|
110 |
+
video_source: Optional[str] = "pexels"
|
111 |
+
video_materials: Optional[List[MaterialInfo]] = None # 用于生成视频的素材
|
112 |
+
|
113 |
+
video_language: Optional[str] = "" # auto detect
|
114 |
+
|
115 |
+
voice_name: Optional[str] = ""
|
116 |
+
voice_volume: Optional[float] = 1.0
|
117 |
+
voice_rate: Optional[float] = 1.0
|
118 |
+
bgm_type: Optional[str] = "random"
|
119 |
+
bgm_file: Optional[str] = ""
|
120 |
+
bgm_volume: Optional[float] = 0.2
|
121 |
+
|
122 |
+
subtitle_enabled: Optional[bool] = True
|
123 |
+
subtitle_position: Optional[str] = "bottom" # top, bottom, center
|
124 |
+
custom_position: float = 70.0
|
125 |
+
font_name: Optional[str] = "STHeitiMedium.ttc"
|
126 |
+
text_fore_color: Optional[str] = "#FFFFFF"
|
127 |
+
text_background_color: Optional[str] = "transparent"
|
128 |
+
|
129 |
+
font_size: int = 60
|
130 |
+
stroke_color: Optional[str] = "#000000"
|
131 |
+
stroke_width: float = 1.5
|
132 |
+
n_threads: Optional[int] = 2
|
133 |
+
paragraph_number: Optional[int] = 1
|
134 |
+
|
135 |
+
|
136 |
+
class SubtitleRequest(BaseModel):
|
137 |
+
video_script: str
|
138 |
+
video_language: Optional[str] = ""
|
139 |
+
voice_name: Optional[str] = "zh-CN-XiaoxiaoNeural-Female"
|
140 |
+
voice_volume: Optional[float] = 1.0
|
141 |
+
voice_rate: Optional[float] = 1.2
|
142 |
+
bgm_type: Optional[str] = "random"
|
143 |
+
bgm_file: Optional[str] = ""
|
144 |
+
bgm_volume: Optional[float] = 0.2
|
145 |
+
subtitle_position: Optional[str] = "bottom"
|
146 |
+
font_name: Optional[str] = "STHeitiMedium.ttc"
|
147 |
+
text_fore_color: Optional[str] = "#FFFFFF"
|
148 |
+
text_background_color: Optional[str] = "transparent"
|
149 |
+
font_size: int = 60
|
150 |
+
stroke_color: Optional[str] = "#000000"
|
151 |
+
stroke_width: float = 1.5
|
152 |
+
video_source: Optional[str] = "local"
|
153 |
+
subtitle_enabled: Optional[str] = "true"
|
154 |
+
|
155 |
+
|
156 |
+
class AudioRequest(BaseModel):
|
157 |
+
video_script: str
|
158 |
+
video_language: Optional[str] = ""
|
159 |
+
voice_name: Optional[str] = "zh-CN-XiaoxiaoNeural-Female"
|
160 |
+
voice_volume: Optional[float] = 1.0
|
161 |
+
voice_rate: Optional[float] = 1.2
|
162 |
+
bgm_type: Optional[str] = "random"
|
163 |
+
bgm_file: Optional[str] = ""
|
164 |
+
bgm_volume: Optional[float] = 0.2
|
165 |
+
video_source: Optional[str] = "local"
|
166 |
+
|
167 |
+
|
168 |
+
class VideoScriptParams:
|
169 |
+
"""
|
170 |
+
{
|
171 |
+
"video_subject": "春天的花海",
|
172 |
+
"video_language": "",
|
173 |
+
"paragraph_number": 1
|
174 |
+
}
|
175 |
+
"""
|
176 |
+
|
177 |
+
video_subject: Optional[str] = "春天的花海"
|
178 |
+
video_language: Optional[str] = ""
|
179 |
+
paragraph_number: Optional[int] = 1
|
180 |
+
|
181 |
+
|
182 |
+
class VideoTermsParams:
|
183 |
+
"""
|
184 |
+
{
|
185 |
+
"video_subject": "",
|
186 |
+
"video_script": "",
|
187 |
+
"amount": 5
|
188 |
+
}
|
189 |
+
"""
|
190 |
+
|
191 |
+
video_subject: Optional[str] = "春天的花海"
|
192 |
+
video_script: Optional[str] = (
|
193 |
+
"春天的花海,如诗如画般展现在眼前。万物复苏的季节里,大地披上了一袭绚丽多彩的盛装。金黄的迎春、粉嫩的樱花、洁白的梨花、艳丽的郁金香……"
|
194 |
+
)
|
195 |
+
amount: Optional[int] = 5
|
196 |
+
|
197 |
+
|
198 |
+
class BaseResponse(BaseModel):
|
199 |
+
status: int = 200
|
200 |
+
message: Optional[str] = "success"
|
201 |
+
data: Any = None
|
202 |
+
|
203 |
+
|
204 |
+
class TaskVideoRequest(VideoParams, BaseModel):
|
205 |
+
pass
|
206 |
+
|
207 |
+
|
208 |
+
class TaskQueryRequest(BaseModel):
|
209 |
+
pass
|
210 |
+
|
211 |
+
|
212 |
+
class VideoScriptRequest(VideoScriptParams, BaseModel):
|
213 |
+
pass
|
214 |
+
|
215 |
+
|
216 |
+
class VideoTermsRequest(VideoTermsParams, BaseModel):
|
217 |
+
pass
|
218 |
+
|
219 |
+
|
220 |
+
######################################################################################################
|
221 |
+
######################################################################################################
|
222 |
+
######################################################################################################
|
223 |
+
######################################################################################################
|
224 |
+
class TaskResponse(BaseResponse):
|
225 |
+
class TaskResponseData(BaseModel):
|
226 |
+
task_id: str
|
227 |
+
|
228 |
+
data: TaskResponseData
|
229 |
+
|
230 |
+
class Config:
|
231 |
+
json_schema_extra = {
|
232 |
+
"example": {
|
233 |
+
"status": 200,
|
234 |
+
"message": "success",
|
235 |
+
"data": {"task_id": "6c85c8cc-a77a-42b9-bc30-947815aa0558"},
|
236 |
+
},
|
237 |
+
}
|
238 |
+
|
239 |
+
|
240 |
+
class TaskQueryResponse(BaseResponse):
|
241 |
+
class Config:
|
242 |
+
json_schema_extra = {
|
243 |
+
"example": {
|
244 |
+
"status": 200,
|
245 |
+
"message": "success",
|
246 |
+
"data": {
|
247 |
+
"state": 1,
|
248 |
+
"progress": 100,
|
249 |
+
"videos": [
|
250 |
+
"http://127.0.0.1:8080/tasks/6c85c8cc-a77a-42b9-bc30-947815aa0558/final-1.mp4"
|
251 |
+
],
|
252 |
+
"combined_videos": [
|
253 |
+
"http://127.0.0.1:8080/tasks/6c85c8cc-a77a-42b9-bc30-947815aa0558/combined-1.mp4"
|
254 |
+
],
|
255 |
+
},
|
256 |
+
},
|
257 |
+
}
|
258 |
+
|
259 |
+
|
260 |
+
class TaskDeletionResponse(BaseResponse):
|
261 |
+
class Config:
|
262 |
+
json_schema_extra = {
|
263 |
+
"example": {
|
264 |
+
"status": 200,
|
265 |
+
"message": "success",
|
266 |
+
"data": {
|
267 |
+
"state": 1,
|
268 |
+
"progress": 100,
|
269 |
+
"videos": [
|
270 |
+
"http://127.0.0.1:8080/tasks/6c85c8cc-a77a-42b9-bc30-947815aa0558/final-1.mp4"
|
271 |
+
],
|
272 |
+
"combined_videos": [
|
273 |
+
"http://127.0.0.1:8080/tasks/6c85c8cc-a77a-42b9-bc30-947815aa0558/combined-1.mp4"
|
274 |
+
],
|
275 |
+
},
|
276 |
+
},
|
277 |
+
}
|
278 |
+
|
279 |
+
|
280 |
+
class VideoScriptResponse(BaseResponse):
|
281 |
+
class Config:
|
282 |
+
json_schema_extra = {
|
283 |
+
"example": {
|
284 |
+
"status": 200,
|
285 |
+
"message": "success",
|
286 |
+
"data": {
|
287 |
+
"video_script": "春天的花海,是大自然的一幅美丽画卷。在这个季节里,大地复苏,万物生长,花朵争相绽放,形成了一片五彩斑斓的花海..."
|
288 |
+
},
|
289 |
+
},
|
290 |
+
}
|
291 |
+
|
292 |
+
|
293 |
+
class VideoTermsResponse(BaseResponse):
|
294 |
+
class Config:
|
295 |
+
json_schema_extra = {
|
296 |
+
"example": {
|
297 |
+
"status": 200,
|
298 |
+
"message": "success",
|
299 |
+
"data": {"video_terms": ["sky", "tree"]},
|
300 |
+
},
|
301 |
+
}
|
302 |
+
|
303 |
+
|
304 |
+
class BgmRetrieveResponse(BaseResponse):
|
305 |
+
class Config:
|
306 |
+
json_schema_extra = {
|
307 |
+
"example": {
|
308 |
+
"status": 200,
|
309 |
+
"message": "success",
|
310 |
+
"data": {
|
311 |
+
"files": [
|
312 |
+
{
|
313 |
+
"name": "output013.mp3",
|
314 |
+
"size": 1891269,
|
315 |
+
"file": "/NarratoAI/resource/songs/output013.mp3",
|
316 |
+
}
|
317 |
+
]
|
318 |
+
},
|
319 |
+
},
|
320 |
+
}
|
321 |
+
|
322 |
+
|
323 |
+
class BgmUploadResponse(BaseResponse):
|
324 |
+
class Config:
|
325 |
+
json_schema_extra = {
|
326 |
+
"example": {
|
327 |
+
"status": 200,
|
328 |
+
"message": "success",
|
329 |
+
"data": {"file": "/NarratoAI/resource/songs/example.mp3"},
|
330 |
+
},
|
331 |
+
}
|
332 |
+
|
333 |
+
|
334 |
+
class VideoClipParams(BaseModel):
|
335 |
+
"""
|
336 |
+
NarratoAI 数据模型
|
337 |
+
"""
|
338 |
+
video_clip_json: Optional[list] = Field(default=[], description="LLM 生成的视频剪辑脚本内容")
|
339 |
+
video_clip_json_path: Optional[str] = Field(default="", description="LLM 生成的视频剪辑脚本路径")
|
340 |
+
video_origin_path: Optional[str] = Field(default="", description="原视频路径")
|
341 |
+
video_aspect: Optional[VideoAspect] = Field(default=VideoAspect.portrait.value, description="视频比例")
|
342 |
+
video_language: Optional[str] = Field(default="zh-CN", description="视频语言")
|
343 |
+
|
344 |
+
# video_clip_duration: Optional[int] = 5 # 视频片段时长
|
345 |
+
# video_count: Optional[int] = 1 # 视频片段数量
|
346 |
+
# video_source: Optional[str] = "local"
|
347 |
+
# video_concat_mode: Optional[VideoConcatMode] = VideoConcatMode.random.value
|
348 |
+
|
349 |
+
voice_name: Optional[str] = Field(default="zh-CN-YunjianNeural", description="语音名称")
|
350 |
+
voice_volume: Optional[float] = Field(default=1.0, description="解说语音音量")
|
351 |
+
voice_rate: Optional[float] = Field(default=1.0, description="语速")
|
352 |
+
voice_pitch: Optional[float] = Field(default=1.0, description="语调")
|
353 |
+
|
354 |
+
bgm_name: Optional[str] = Field(default="random", description="背景音乐名称")
|
355 |
+
bgm_type: Optional[str] = Field(default="random", description="背景音乐类型")
|
356 |
+
bgm_file: Optional[str] = Field(default="", description="背景音乐文件")
|
357 |
+
|
358 |
+
subtitle_enabled: bool = True
|
359 |
+
font_name: str = "SimHei" # 默认使用黑体
|
360 |
+
font_size: int = 36
|
361 |
+
text_fore_color: str = "white" # 文本前景色
|
362 |
+
text_back_color: Optional[str] = None # 文本背景色
|
363 |
+
stroke_color: str = "black" # 描边颜色
|
364 |
+
stroke_width: float = 1.5 # 描边宽度
|
365 |
+
subtitle_position: str = "bottom" # top, bottom, center, custom
|
366 |
+
custom_position: float = 70.0 # 自定义位置
|
367 |
+
|
368 |
+
n_threads: Optional[int] = Field(default=16, description="线程数") # 线程数,有助于提升视频处理速度
|
369 |
+
|
370 |
+
tts_volume: Optional[float] = Field(default=1.0, description="解说语音音量(后处理)")
|
371 |
+
original_volume: Optional[float] = Field(default=1.0, description="视频原声音量")
|
372 |
+
bgm_volume: Optional[float] = Field(default=0.3, description="背景音乐音量")
|
373 |
+
|
374 |
+
|
375 |
+
class VideoTranscriptionRequest(BaseModel):
|
376 |
+
video_name: str
|
377 |
+
language: str = "zh-CN"
|
378 |
+
|
379 |
+
class Config:
|
380 |
+
arbitrary_types_allowed = True
|
381 |
+
|
382 |
+
|
383 |
+
class VideoTranscriptionResponse(BaseModel):
|
384 |
+
transcription: str
|
385 |
+
|
386 |
+
|
387 |
+
class SubtitlePosition(str, Enum):
|
388 |
+
TOP = "top"
|
389 |
+
CENTER = "center"
|
390 |
+
BOTTOM = "bottom"
|
391 |
+
|
app/models/schema_v2.py
ADDED
@@ -0,0 +1,63 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from typing import Optional, List
|
2 |
+
from pydantic import BaseModel
|
3 |
+
|
4 |
+
|
5 |
+
class GenerateScriptRequest(BaseModel):
|
6 |
+
video_path: str
|
7 |
+
video_theme: Optional[str] = ""
|
8 |
+
custom_prompt: Optional[str] = ""
|
9 |
+
frame_interval_input: Optional[int] = 5
|
10 |
+
skip_seconds: Optional[int] = 0
|
11 |
+
threshold: Optional[int] = 30
|
12 |
+
vision_batch_size: Optional[int] = 5
|
13 |
+
vision_llm_provider: Optional[str] = "gemini"
|
14 |
+
|
15 |
+
|
16 |
+
class GenerateScriptResponse(BaseModel):
|
17 |
+
task_id: str
|
18 |
+
script: List[dict]
|
19 |
+
|
20 |
+
|
21 |
+
class CropVideoRequest(BaseModel):
|
22 |
+
video_origin_path: str
|
23 |
+
video_script: List[dict]
|
24 |
+
|
25 |
+
|
26 |
+
class CropVideoResponse(BaseModel):
|
27 |
+
task_id: str
|
28 |
+
subclip_videos: dict
|
29 |
+
|
30 |
+
|
31 |
+
class DownloadVideoRequest(BaseModel):
|
32 |
+
url: str
|
33 |
+
resolution: str
|
34 |
+
output_format: Optional[str] = "mp4"
|
35 |
+
rename: Optional[str] = None
|
36 |
+
|
37 |
+
|
38 |
+
class DownloadVideoResponse(BaseModel):
|
39 |
+
task_id: str
|
40 |
+
output_path: str
|
41 |
+
resolution: str
|
42 |
+
format: str
|
43 |
+
filename: str
|
44 |
+
|
45 |
+
|
46 |
+
class StartSubclipRequest(BaseModel):
|
47 |
+
task_id: str
|
48 |
+
video_origin_path: str
|
49 |
+
video_clip_json_path: str
|
50 |
+
voice_name: Optional[str] = None
|
51 |
+
voice_rate: Optional[int] = 0
|
52 |
+
voice_pitch: Optional[int] = 0
|
53 |
+
subtitle_enabled: Optional[bool] = True
|
54 |
+
video_aspect: Optional[str] = "16:9"
|
55 |
+
n_threads: Optional[int] = 4
|
56 |
+
subclip_videos: list # 从裁剪视频接口获取的视频片段字典
|
57 |
+
|
58 |
+
|
59 |
+
class StartSubclipResponse(BaseModel):
|
60 |
+
task_id: str
|
61 |
+
state: str
|
62 |
+
videos: Optional[List[str]] = None
|
63 |
+
combined_videos: Optional[List[str]] = None
|
app/router.py
ADDED
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"""Application configuration - root APIRouter.
|
2 |
+
|
3 |
+
Defines all FastAPI application endpoints.
|
4 |
+
|
5 |
+
Resources:
|
6 |
+
1. https://fastapi.tiangolo.com/tutorial/bigger-applications
|
7 |
+
|
8 |
+
"""
|
9 |
+
|
10 |
+
from fastapi import APIRouter
|
11 |
+
|
12 |
+
from app.controllers.v1 import llm, video
|
13 |
+
from app.controllers.v2 import script
|
14 |
+
|
15 |
+
root_api_router = APIRouter()
|
16 |
+
# v1
|
17 |
+
root_api_router.include_router(video.router)
|
18 |
+
root_api_router.include_router(llm.router)
|
19 |
+
|
20 |
+
# v2
|
21 |
+
root_api_router.include_router(script.router)
|
app/services/SDE/prompt.py
ADDED
@@ -0,0 +1,97 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
#!/usr/bin/env python
|
2 |
+
# -*- coding: UTF-8 -*-
|
3 |
+
|
4 |
+
'''
|
5 |
+
@Project: NarratoAI
|
6 |
+
@File : prompt
|
7 |
+
@Author : 小林同学
|
8 |
+
@Date : 2025/5/9 上午12:57
|
9 |
+
'''
|
10 |
+
# 字幕剧情分析提示词
|
11 |
+
subtitle_plot_analysis_v1 = """
|
12 |
+
# 角色
|
13 |
+
你是一位专业的剧本分析师和剧情概括助手。
|
14 |
+
|
15 |
+
# 任务
|
16 |
+
我将为你提供一部短剧的完整字幕文本。请你基于这些字幕,完成以下任务:
|
17 |
+
1. **整体剧情分析**:简要概括整个短剧的核心剧情脉络、主要冲突和结局(如果有的话)。
|
18 |
+
2. **分段剧情解析与时间戳定位**:
|
19 |
+
* 将整个短剧划分为若干个关键的剧情段落(例如:开端、发展、转折、高潮、结局,或根据具体情节自然划分)。
|
20 |
+
* 段落数应该与字幕长度成正比。
|
21 |
+
* 对于每一个剧情段落:
|
22 |
+
* **概括该段落的主要内容**:用简洁的语言描述这段剧情发生了什么。
|
23 |
+
* **标注对应的时间戳范围**:明确指出该剧情段落对应的开始字幕时间戳和结束字幕时间戳。请直接从字幕中提取时间信息。
|
24 |
+
|
25 |
+
# 输入格式
|
26 |
+
字幕内容通常包含时间戳和对话,例如:
|
27 |
+
```
|
28 |
+
00:00:05,000 --> 00:00:10,000
|
29 |
+
[角色A]: 你好吗?
|
30 |
+
00:00:10,500 --> 00:00:15,000
|
31 |
+
[角色B]: 我很好,谢谢。发生了一些有趣的事情。
|
32 |
+
... (更多字幕内容) ...
|
33 |
+
```
|
34 |
+
我将把实际字幕粘贴在下方。
|
35 |
+
|
36 |
+
# 输出格式要求
|
37 |
+
请按照以下格式清晰地呈现分析结果:
|
38 |
+
|
39 |
+
**一、整体剧情概括:**
|
40 |
+
[此处填写对整个短剧剧情的概括]
|
41 |
+
|
42 |
+
**二、分段剧情解析:**
|
43 |
+
|
44 |
+
**剧情段落 1:[段落主题/概括,例如:主角登场与背景介绍]**
|
45 |
+
* **时间戳:** [开始时间戳] --> [结束时间戳]
|
46 |
+
* **内容概要:** [对这段剧情的详细描述]
|
47 |
+
|
48 |
+
**剧情段落 2:[段落主题/概括,例如:第一个冲突出现]**
|
49 |
+
* **时间戳:** [开始时间戳] --> [结束时间戳]
|
50 |
+
* **内容概要:** [对这段剧情的详细描述]
|
51 |
+
|
52 |
+
... (根据实际剧情段落数量继续) ...
|
53 |
+
|
54 |
+
**剧情段落 N:[段落主题/概括,例如:结局与反思]**
|
55 |
+
* **时间戳:** [开始时间戳] --> [结束时间戳]
|
56 |
+
* **内容概要:** [对这段剧情的详细描述]
|
57 |
+
|
58 |
+
# 注意事项
|
59 |
+
* 请确保时间戳的准确性,直接引用字幕中的时间。
|
60 |
+
* 剧情段落的划分应合乎逻辑,能够反映剧情的起承转合。
|
61 |
+
* 语言表达应简洁、准确、客观。
|
62 |
+
|
63 |
+
# 限制
|
64 |
+
1. 严禁输出与分析结果无关的内容
|
65 |
+
2.
|
66 |
+
|
67 |
+
# 请处理以下字幕:
|
68 |
+
"""
|
69 |
+
|
70 |
+
plot_writing = """
|
71 |
+
我是一个影视解说up主,需要为我的粉丝讲解短剧《%s》的剧情,目前正在解说剧情,希望能让粉丝通过我的解说了解剧情,并且产生 继续观看的兴趣,请生成一篇解说脚本,包含解说文案,以及穿插原声的片段,下面<plot>中的内容是短剧的剧情概述:
|
72 |
+
|
73 |
+
<plot>
|
74 |
+
%s
|
75 |
+
</plot>
|
76 |
+
|
77 |
+
请使用 json 格式进行输出;使用 <output> 中的输出格式:
|
78 |
+
<output>
|
79 |
+
{
|
80 |
+
"items": [
|
81 |
+
{
|
82 |
+
"_id": 1, # 唯一递增id
|
83 |
+
"timestamp": "00:00:05,390-00:00:10,430",
|
84 |
+
"picture": "剧情描述或者备注",
|
85 |
+
"narration": "解说文案,如果片段为穿插的原片片段,可以直接使用 ‘播放原片+_id‘ 进行占位",
|
86 |
+
"OST": "值为 0 表示当前片段为解说片段,值为 1 表示当前片段为穿插的原片"
|
87 |
+
}
|
88 |
+
}
|
89 |
+
</output>
|
90 |
+
|
91 |
+
<restriction>
|
92 |
+
1. 只输出 json 内容,不要输出其他任何说明性的文字
|
93 |
+
2. 解说文案的语言使用 简体中文
|
94 |
+
3. 严禁虚构剧情,所有画面只能从 <polt> 中摘取
|
95 |
+
4. 严禁虚构时间戳,所有时间戳范围只能从 <polt> 中摘取
|
96 |
+
</restriction>
|
97 |
+
"""
|
app/services/SDE/short_drama_explanation.py
ADDED
@@ -0,0 +1,456 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
#!/usr/bin/env python
|
2 |
+
# -*- coding: UTF-8 -*-
|
3 |
+
|
4 |
+
'''
|
5 |
+
@Project: NarratoAI
|
6 |
+
@File : 短剧解说
|
7 |
+
@Author : 小林同学
|
8 |
+
@Date : 2025/5/9 上午12:36
|
9 |
+
'''
|
10 |
+
|
11 |
+
import os
|
12 |
+
import json
|
13 |
+
import requests
|
14 |
+
from typing import Dict, Any, Optional
|
15 |
+
from loguru import logger
|
16 |
+
from app.config import config
|
17 |
+
from app.utils.utils import get_uuid, storage_dir
|
18 |
+
from app.services.SDE.prompt import subtitle_plot_analysis_v1, plot_writing
|
19 |
+
|
20 |
+
|
21 |
+
class SubtitleAnalyzer:
|
22 |
+
"""字幕剧情分析器,负责分析字幕内容并提取关键剧情段落"""
|
23 |
+
|
24 |
+
def __init__(
|
25 |
+
self,
|
26 |
+
api_key: Optional[str] = None,
|
27 |
+
model: Optional[str] = None,
|
28 |
+
base_url: Optional[str] = None,
|
29 |
+
custom_prompt: Optional[str] = None,
|
30 |
+
temperature: Optional[float] = 1.0,
|
31 |
+
):
|
32 |
+
"""
|
33 |
+
初始化字幕分析器
|
34 |
+
|
35 |
+
Args:
|
36 |
+
api_key: API密钥,如果不提供则从配置中读取
|
37 |
+
model: 模型名称,如果不提供则从配置中读取
|
38 |
+
base_url: API基础URL,如果不提供则从配置中读取或使用默认值
|
39 |
+
custom_prompt: 自定义提示词,如果不提供则使用默认值
|
40 |
+
temperature: 模型温度
|
41 |
+
"""
|
42 |
+
# 使用传入的参数或从配置中获取
|
43 |
+
self.api_key = api_key
|
44 |
+
self.model = model
|
45 |
+
self.base_url = base_url
|
46 |
+
self.temperature = temperature
|
47 |
+
|
48 |
+
# 设置提示词模板
|
49 |
+
self.prompt_template = custom_prompt or subtitle_plot_analysis_v1
|
50 |
+
|
51 |
+
# 初始化HTTP请求所需的头信息
|
52 |
+
self._init_headers()
|
53 |
+
|
54 |
+
def _init_headers(self):
|
55 |
+
"""初始化HTTP请求头"""
|
56 |
+
try:
|
57 |
+
# 基础请求头,包含API密钥和内容类型
|
58 |
+
self.headers = {
|
59 |
+
"Content-Type": "application/json",
|
60 |
+
"Authorization": f"Bearer {self.api_key}"
|
61 |
+
}
|
62 |
+
# logger.debug(f"初始化成功 - API Key: {self.api_key[:8]}... - Base URL: {self.base_url}")
|
63 |
+
except Exception as e:
|
64 |
+
logger.error(f"初始化请求头失败: {str(e)}")
|
65 |
+
raise
|
66 |
+
|
67 |
+
def analyze_subtitle(self, subtitle_content: str) -> Dict[str, Any]:
|
68 |
+
"""
|
69 |
+
分析字幕内容
|
70 |
+
|
71 |
+
Args:
|
72 |
+
subtitle_content: 字幕内容文本
|
73 |
+
|
74 |
+
Returns:
|
75 |
+
Dict[str, Any]: 包含分析结果的字典
|
76 |
+
"""
|
77 |
+
try:
|
78 |
+
# 构建完整提示词
|
79 |
+
prompt = f"{self.prompt_template}\n\n{subtitle_content}"
|
80 |
+
|
81 |
+
# 构建请求体数据
|
82 |
+
payload = {
|
83 |
+
"model": self.model,
|
84 |
+
"messages": [
|
85 |
+
{"role": "system", "content": "你是一位专业的剧本分析师和剧情概括助手。"},
|
86 |
+
{"role": "user", "content": prompt}
|
87 |
+
],
|
88 |
+
"temperature": self.temperature
|
89 |
+
}
|
90 |
+
|
91 |
+
# 构建请求地址
|
92 |
+
url = f"{self.base_url}/chat/completions"
|
93 |
+
|
94 |
+
# 发送HTTP请求
|
95 |
+
response = requests.post(url, headers=self.headers, json=payload)
|
96 |
+
|
97 |
+
# 解析响应
|
98 |
+
if response.status_code == 200:
|
99 |
+
response_data = response.json()
|
100 |
+
|
101 |
+
# 提取响应内容
|
102 |
+
if "choices" in response_data and len(response_data["choices"]) > 0:
|
103 |
+
analysis_result = response_data["choices"][0]["message"]["content"]
|
104 |
+
logger.debug(f"字幕分析完成,消耗的tokens: {response_data.get('usage', {}).get('total_tokens', 0)}")
|
105 |
+
|
106 |
+
# 返回结果
|
107 |
+
return {
|
108 |
+
"status": "success",
|
109 |
+
"analysis": analysis_result,
|
110 |
+
"tokens_used": response_data.get("usage", {}).get("total_tokens", 0),
|
111 |
+
"model": self.model,
|
112 |
+
"temperature": self.temperature
|
113 |
+
}
|
114 |
+
else:
|
115 |
+
logger.error("字幕分析失败: 未获取到有效响应")
|
116 |
+
return {
|
117 |
+
"status": "error",
|
118 |
+
"message": "未获取到有效响应",
|
119 |
+
"temperature": self.temperature
|
120 |
+
}
|
121 |
+
else:
|
122 |
+
error_msg = f"请求失败,状态码: {response.status_code}, 响应: {response.text}"
|
123 |
+
logger.error(error_msg)
|
124 |
+
return {
|
125 |
+
"status": "error",
|
126 |
+
"message": error_msg,
|
127 |
+
"temperature": self.temperature
|
128 |
+
}
|
129 |
+
|
130 |
+
except Exception as e:
|
131 |
+
logger.error(f"字幕分析过程中发生错误: {str(e)}")
|
132 |
+
return {
|
133 |
+
"status": "error",
|
134 |
+
"message": str(e),
|
135 |
+
"temperature": self.temperature
|
136 |
+
}
|
137 |
+
|
138 |
+
def analyze_subtitle_from_file(self, subtitle_file_path: str) -> Dict[str, Any]:
|
139 |
+
"""
|
140 |
+
从文件读取字幕并分析
|
141 |
+
|
142 |
+
Args:
|
143 |
+
subtitle_file_path: 字幕文件的路径
|
144 |
+
|
145 |
+
Returns:
|
146 |
+
Dict[str, Any]: 包含分析结果的字典
|
147 |
+
"""
|
148 |
+
try:
|
149 |
+
# 检查文件是否存在
|
150 |
+
if not os.path.exists(subtitle_file_path):
|
151 |
+
return {
|
152 |
+
"status": "error",
|
153 |
+
"message": f"字幕文件不存在: {subtitle_file_path}",
|
154 |
+
"temperature": self.temperature
|
155 |
+
}
|
156 |
+
|
157 |
+
# 读取文件内容
|
158 |
+
with open(subtitle_file_path, 'r', encoding='utf-8') as f:
|
159 |
+
subtitle_content = f.read()
|
160 |
+
|
161 |
+
# 分析字幕
|
162 |
+
return self.analyze_subtitle(subtitle_content)
|
163 |
+
|
164 |
+
except Exception as e:
|
165 |
+
logger.error(f"从文件读取字幕并分析过程中发生错误: {str(e)}")
|
166 |
+
return {
|
167 |
+
"status": "error",
|
168 |
+
"message": str(e),
|
169 |
+
"temperature": self.temperature
|
170 |
+
}
|
171 |
+
|
172 |
+
def save_analysis_result(self, analysis_result: Dict[str, Any], output_path: Optional[str] = None) -> str:
|
173 |
+
"""
|
174 |
+
保存分析结果到文件
|
175 |
+
|
176 |
+
Args:
|
177 |
+
analysis_result: 分析结果
|
178 |
+
output_path: 输出文件路径,如果不提供则自动生成
|
179 |
+
|
180 |
+
Returns:
|
181 |
+
str: 输出文件的路径
|
182 |
+
"""
|
183 |
+
try:
|
184 |
+
# 如果未提供输出路径,则自动生成
|
185 |
+
if not output_path:
|
186 |
+
output_dir = storage_dir("drama_analysis", create=True)
|
187 |
+
output_path = os.path.join(output_dir, f"analysis_{get_uuid(True)}.txt")
|
188 |
+
|
189 |
+
# 确保目录存在
|
190 |
+
os.makedirs(os.path.dirname(output_path), exist_ok=True)
|
191 |
+
|
192 |
+
# 保存结果
|
193 |
+
with open(output_path, 'w', encoding='utf-8') as f:
|
194 |
+
if analysis_result["status"] == "success":
|
195 |
+
f.write(analysis_result["analysis"])
|
196 |
+
else:
|
197 |
+
f.write(f"分析失败: {analysis_result['message']}")
|
198 |
+
|
199 |
+
logger.info(f"分析结果已保存到: {output_path}")
|
200 |
+
return output_path
|
201 |
+
|
202 |
+
except Exception as e:
|
203 |
+
logger.error(f"保存分析结果时发生错误: {str(e)}")
|
204 |
+
return ""
|
205 |
+
|
206 |
+
def generate_narration_script(self, short_name:str, plot_analysis: str, temperature: float = 0.7) -> Dict[str, Any]:
|
207 |
+
"""
|
208 |
+
根据剧情分析生成解说文案
|
209 |
+
|
210 |
+
Args:
|
211 |
+
short_name: 短剧名称
|
212 |
+
plot_analysis: 剧情分析内容
|
213 |
+
temperature: 生成温度,控制创造性,默认0.7
|
214 |
+
|
215 |
+
Returns:
|
216 |
+
Dict[str, Any]: 包含生成结果的字典
|
217 |
+
"""
|
218 |
+
try:
|
219 |
+
# 构建完整提示词
|
220 |
+
prompt = plot_writing % (short_name, plot_analysis)
|
221 |
+
|
222 |
+
# 构建请求体数据
|
223 |
+
payload = {
|
224 |
+
"model": self.model,
|
225 |
+
"messages": [
|
226 |
+
{"role": "system", "content": "你是一位专业的短视频解说脚本撰写专家。"},
|
227 |
+
{"role": "user", "content": prompt}
|
228 |
+
],
|
229 |
+
"temperature": temperature
|
230 |
+
}
|
231 |
+
|
232 |
+
# 对特定模型添加响应格式设置
|
233 |
+
if self.model not in ["deepseek-reasoner"]:
|
234 |
+
payload["response_format"] = {"type": "json_object"}
|
235 |
+
|
236 |
+
# 构建请求地址
|
237 |
+
url = f"{self.base_url}/chat/completions"
|
238 |
+
|
239 |
+
# 发送HTTP请求
|
240 |
+
response = requests.post(url, headers=self.headers, json=payload)
|
241 |
+
|
242 |
+
# 解析响应
|
243 |
+
if response.status_code == 200:
|
244 |
+
response_data = response.json()
|
245 |
+
|
246 |
+
# 提取响应内容
|
247 |
+
if "choices" in response_data and len(response_data["choices"]) > 0:
|
248 |
+
narration_script = response_data["choices"][0]["message"]["content"]
|
249 |
+
logger.debug(f"解说文案生成完成,消耗的tokens: {response_data.get('usage', {}).get('total_tokens', 0)}")
|
250 |
+
|
251 |
+
# 返回结果
|
252 |
+
return {
|
253 |
+
"status": "success",
|
254 |
+
"narration_script": narration_script,
|
255 |
+
"tokens_used": response_data.get("usage", {}).get("total_tokens", 0),
|
256 |
+
"model": self.model,
|
257 |
+
"temperature": self.temperature
|
258 |
+
}
|
259 |
+
else:
|
260 |
+
logger.error("解说文案生成失败: 未获取到有效响应")
|
261 |
+
return {
|
262 |
+
"status": "error",
|
263 |
+
"message": "未获取到有效���应",
|
264 |
+
"temperature": self.temperature
|
265 |
+
}
|
266 |
+
else:
|
267 |
+
error_msg = f"请求失败,状态码: {response.status_code}, 响应: {response.text}"
|
268 |
+
logger.error(error_msg)
|
269 |
+
return {
|
270 |
+
"status": "error",
|
271 |
+
"message": error_msg,
|
272 |
+
"temperature": self.temperature
|
273 |
+
}
|
274 |
+
|
275 |
+
except Exception as e:
|
276 |
+
logger.error(f"解说文案生成过程中发生错误: {str(e)}")
|
277 |
+
return {
|
278 |
+
"status": "error",
|
279 |
+
"message": str(e),
|
280 |
+
"temperature": self.temperature
|
281 |
+
}
|
282 |
+
|
283 |
+
def save_narration_script(self, narration_result: Dict[str, Any], output_path: Optional[str] = None) -> str:
|
284 |
+
"""
|
285 |
+
保存解说文案到文件
|
286 |
+
|
287 |
+
Args:
|
288 |
+
narration_result: 解说文案生成结果
|
289 |
+
output_path: 输出文件路径,如果不提供则自动生成
|
290 |
+
|
291 |
+
Returns:
|
292 |
+
str: 输出文件的路径
|
293 |
+
"""
|
294 |
+
try:
|
295 |
+
# 如果未提供输出路径,则自动生成
|
296 |
+
if not output_path:
|
297 |
+
output_dir = storage_dir("narration_scripts", create=True)
|
298 |
+
output_path = os.path.join(output_dir, f"narration_{get_uuid(True)}.json")
|
299 |
+
|
300 |
+
# 确保目录存在
|
301 |
+
os.makedirs(os.path.dirname(output_path), exist_ok=True)
|
302 |
+
|
303 |
+
# 保存结果
|
304 |
+
with open(output_path, 'w', encoding='utf-8') as f:
|
305 |
+
if narration_result["status"] == "success":
|
306 |
+
f.write(narration_result["narration_script"])
|
307 |
+
else:
|
308 |
+
f.write(f"生成失败: {narration_result['message']}")
|
309 |
+
|
310 |
+
logger.info(f"解说文案已保存到: {output_path}")
|
311 |
+
return output_path
|
312 |
+
|
313 |
+
except Exception as e:
|
314 |
+
logger.error(f"保存解说文案时发生错误: {str(e)}")
|
315 |
+
return ""
|
316 |
+
|
317 |
+
|
318 |
+
def analyze_subtitle(
|
319 |
+
subtitle_content: str = None,
|
320 |
+
subtitle_file_path: str = None,
|
321 |
+
api_key: Optional[str] = None,
|
322 |
+
model: Optional[str] = None,
|
323 |
+
base_url: Optional[str] = None,
|
324 |
+
custom_prompt: Optional[str] = None,
|
325 |
+
temperature: float = 1.0,
|
326 |
+
save_result: bool = False,
|
327 |
+
output_path: Optional[str] = None
|
328 |
+
) -> Dict[str, Any]:
|
329 |
+
"""
|
330 |
+
分析字幕内容的便捷函数
|
331 |
+
|
332 |
+
Args:
|
333 |
+
subtitle_content: 字幕内容文本
|
334 |
+
subtitle_file_path: 字幕文件路径
|
335 |
+
custom_prompt: 自定义提示词
|
336 |
+
api_key: API密钥
|
337 |
+
model: 模型名称
|
338 |
+
base_url: API基础URL
|
339 |
+
temperature: 模型温度
|
340 |
+
save_result: 是否保存结果到文件
|
341 |
+
output_path: 输出文件路径
|
342 |
+
|
343 |
+
Returns:
|
344 |
+
Dict[str, Any]: 包含分析结果的字典
|
345 |
+
"""
|
346 |
+
# 初始化分析器
|
347 |
+
analyzer = SubtitleAnalyzer(
|
348 |
+
temperature=temperature,
|
349 |
+
api_key=api_key,
|
350 |
+
model=model,
|
351 |
+
base_url=base_url,
|
352 |
+
custom_prompt=custom_prompt
|
353 |
+
)
|
354 |
+
logger.debug(f"使用模型: {analyzer.model} 开始分析, 温度: {analyzer.temperature}")
|
355 |
+
# 分析字幕
|
356 |
+
if subtitle_content:
|
357 |
+
result = analyzer.analyze_subtitle(subtitle_content)
|
358 |
+
elif subtitle_file_path:
|
359 |
+
result = analyzer.analyze_subtitle_from_file(subtitle_file_path)
|
360 |
+
else:
|
361 |
+
return {
|
362 |
+
"status": "error",
|
363 |
+
"message": "必须提供字幕内容或字幕文件路径",
|
364 |
+
"temperature": temperature
|
365 |
+
}
|
366 |
+
|
367 |
+
# 保存结果
|
368 |
+
if save_result and result["status"] == "success":
|
369 |
+
result["output_path"] = analyzer.save_analysis_result(result, output_path)
|
370 |
+
|
371 |
+
return result
|
372 |
+
|
373 |
+
|
374 |
+
def generate_narration_script(
|
375 |
+
short_name: str = None,
|
376 |
+
plot_analysis: str = None,
|
377 |
+
api_key: Optional[str] = None,
|
378 |
+
model: Optional[str] = None,
|
379 |
+
base_url: Optional[str] = None,
|
380 |
+
temperature: float = 1.0,
|
381 |
+
save_result: bool = False,
|
382 |
+
output_path: Optional[str] = None
|
383 |
+
) -> Dict[str, Any]:
|
384 |
+
"""
|
385 |
+
根据剧情分析生成解说文案的便捷函数
|
386 |
+
|
387 |
+
Args:
|
388 |
+
short_name: 短剧名称
|
389 |
+
plot_analysis: 剧情分析内容,直接提供
|
390 |
+
api_key: API密钥
|
391 |
+
model: 模型名称
|
392 |
+
base_url: API基础URL
|
393 |
+
temperature: 生成温度,控制创造性
|
394 |
+
save_result: 是否保存结果到文件
|
395 |
+
output_path: 输出文件路径
|
396 |
+
|
397 |
+
Returns:
|
398 |
+
Dict[str, Any]: 包含生成结果的字典
|
399 |
+
"""
|
400 |
+
# 初始化分析器
|
401 |
+
analyzer = SubtitleAnalyzer(
|
402 |
+
temperature=temperature,
|
403 |
+
api_key=api_key,
|
404 |
+
model=model,
|
405 |
+
base_url=base_url
|
406 |
+
)
|
407 |
+
|
408 |
+
# 生成解说文案
|
409 |
+
result = analyzer.generate_narration_script(short_name, plot_analysis, temperature)
|
410 |
+
|
411 |
+
# 保存结果
|
412 |
+
if save_result and result["status"] == "success":
|
413 |
+
result["output_path"] = analyzer.save_narration_script(result, output_path)
|
414 |
+
|
415 |
+
return result
|
416 |
+
|
417 |
+
|
418 |
+
if __name__ == '__main__':
|
419 |
+
text_api_key = "skxxxx"
|
420 |
+
text_model = "gemini-2.0-flash"
|
421 |
+
text_base_url = "https://api.narratoai.cn/v1/chat/completions" # 确保URL不以斜杠结尾,便于后续拼接
|
422 |
+
subtitle_path = "/Users/apple/Desktop/home/NarratoAI/resource/srt/家里家外1-5.srt"
|
423 |
+
|
424 |
+
# 示例用法
|
425 |
+
if subtitle_path:
|
426 |
+
# 分析字幕总结剧情
|
427 |
+
analysis_result = analyze_subtitle(
|
428 |
+
subtitle_file_path=subtitle_path,
|
429 |
+
api_key=text_api_key,
|
430 |
+
model=text_model,
|
431 |
+
base_url=text_base_url,
|
432 |
+
save_result=True
|
433 |
+
)
|
434 |
+
|
435 |
+
if analysis_result["status"] == "success":
|
436 |
+
print("字幕分析成功!")
|
437 |
+
print("分析结果:")
|
438 |
+
print(analysis_result["analysis"])
|
439 |
+
|
440 |
+
# 根据剧情生成解说文案
|
441 |
+
narration_result = generate_narration_script(
|
442 |
+
plot_analysis=analysis_result["analysis"],
|
443 |
+
api_key=text_api_key,
|
444 |
+
model=text_model,
|
445 |
+
base_url=text_base_url,
|
446 |
+
save_result=True
|
447 |
+
)
|
448 |
+
|
449 |
+
if narration_result["status"] == "success":
|
450 |
+
print("\n解说文案生成成功!")
|
451 |
+
print("解说文案:")
|
452 |
+
print(narration_result["narration_script"])
|
453 |
+
else:
|
454 |
+
print(f"\n解说文案生成失败: {narration_result['message']}")
|
455 |
+
else:
|
456 |
+
print(f"分析失败: {analysis_result['message']}")
|
app/services/SDP/generate_script_short.py
ADDED
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"""
|
2 |
+
视频脚本生成pipeline,串联各个处理步骤
|
3 |
+
"""
|
4 |
+
import os
|
5 |
+
from .utils.step1_subtitle_analyzer_openai import analyze_subtitle
|
6 |
+
from .utils.step5_merge_script import merge_script
|
7 |
+
|
8 |
+
|
9 |
+
def generate_script(srt_path: str, api_key: str, model_name: str, output_path: str, base_url: str = None, custom_clips: int = 5):
|
10 |
+
"""生成视频混剪脚本
|
11 |
+
|
12 |
+
Args:
|
13 |
+
srt_path: 字幕文件路径
|
14 |
+
output_path: 输出文件路径,可选
|
15 |
+
|
16 |
+
Returns:
|
17 |
+
str: 生成的脚本内容
|
18 |
+
"""
|
19 |
+
# 验证输入文件
|
20 |
+
if not os.path.exists(srt_path):
|
21 |
+
raise FileNotFoundError(f"字幕文件不存在: {srt_path}")
|
22 |
+
|
23 |
+
# 分析字幕
|
24 |
+
print("开始分析...")
|
25 |
+
openai_analysis = analyze_subtitle(
|
26 |
+
srt_path=srt_path,
|
27 |
+
api_key=api_key,
|
28 |
+
model_name=model_name,
|
29 |
+
base_url=base_url,
|
30 |
+
custom_clips=custom_clips
|
31 |
+
)
|
32 |
+
|
33 |
+
# 合并生成最终脚本
|
34 |
+
adjusted_results = openai_analysis['plot_points']
|
35 |
+
final_script = merge_script(adjusted_results, output_path)
|
36 |
+
|
37 |
+
return final_script
|
app/services/SDP/utils/short_schema.py
ADDED
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"""
|
2 |
+
定义项目中使用的数据类型
|
3 |
+
"""
|
4 |
+
from typing import List, Dict, Optional
|
5 |
+
from dataclasses import dataclass
|
6 |
+
|
7 |
+
|
8 |
+
@dataclass
|
9 |
+
class PlotPoint:
|
10 |
+
timestamp: str
|
11 |
+
title: str
|
12 |
+
picture: str
|
13 |
+
|
14 |
+
|
15 |
+
@dataclass
|
16 |
+
class Commentary:
|
17 |
+
timestamp: str
|
18 |
+
title: str
|
19 |
+
copywriter: str
|
20 |
+
|
21 |
+
|
22 |
+
@dataclass
|
23 |
+
class SubtitleSegment:
|
24 |
+
start_time: float
|
25 |
+
end_time: float
|
26 |
+
text: str
|
27 |
+
|
28 |
+
|
29 |
+
@dataclass
|
30 |
+
class ScriptItem:
|
31 |
+
timestamp: str
|
32 |
+
title: str
|
33 |
+
picture: str
|
34 |
+
copywriter: str
|
35 |
+
|
36 |
+
|
37 |
+
@dataclass
|
38 |
+
class PipelineResult:
|
39 |
+
output_video_path: str
|
40 |
+
plot_points: List[PlotPoint]
|
41 |
+
subtitle_segments: List[SubtitleSegment]
|
42 |
+
commentaries: List[Commentary]
|
43 |
+
final_script: List[ScriptItem]
|
44 |
+
error: Optional[str] = None
|
45 |
+
|
46 |
+
|
47 |
+
class VideoProcessingError(Exception):
|
48 |
+
pass
|
49 |
+
|
50 |
+
|
51 |
+
class SubtitleProcessingError(Exception):
|
52 |
+
pass
|
53 |
+
|
54 |
+
|
55 |
+
class PlotAnalysisError(Exception):
|
56 |
+
pass
|
57 |
+
|
58 |
+
|
59 |
+
class CopywritingError(Exception):
|
60 |
+
pass
|
app/services/SDP/utils/step1_subtitle_analyzer_openai.py
ADDED
@@ -0,0 +1,157 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"""
|
2 |
+
使用OpenAI API,分析字幕文件,返回剧情梗概和爆点
|
3 |
+
"""
|
4 |
+
import traceback
|
5 |
+
from openai import OpenAI, BadRequestError
|
6 |
+
import os
|
7 |
+
import json
|
8 |
+
|
9 |
+
from .utils import load_srt
|
10 |
+
|
11 |
+
|
12 |
+
def analyze_subtitle(
|
13 |
+
srt_path: str,
|
14 |
+
model_name: str,
|
15 |
+
api_key: str = None,
|
16 |
+
base_url: str = None,
|
17 |
+
custom_clips: int = 5
|
18 |
+
) -> dict:
|
19 |
+
"""分析字幕内容,返回完整的分析结果
|
20 |
+
|
21 |
+
Args:
|
22 |
+
srt_path (str): SRT字幕文件路径
|
23 |
+
api_key (str, optional): 大模型API密钥. Defaults to None.
|
24 |
+
model_name (str, optional): 大模型名称. Defaults to "gpt-4o-2024-11-20".
|
25 |
+
base_url (str, optional): 大模型API基础URL. Defaults to None.
|
26 |
+
|
27 |
+
Returns:
|
28 |
+
dict: 包含剧情梗概和结构化的时间段分析的字典
|
29 |
+
"""
|
30 |
+
try:
|
31 |
+
# 加载字幕文件
|
32 |
+
subtitles = load_srt(srt_path)
|
33 |
+
subtitle_content = "\n".join([f"{sub['timestamp']}\n{sub['text']}" for sub in subtitles])
|
34 |
+
|
35 |
+
# 初始化客户端
|
36 |
+
global client
|
37 |
+
if "deepseek" in model_name.lower():
|
38 |
+
client = OpenAI(
|
39 |
+
api_key=api_key or os.getenv('DeepSeek_API_KEY'),
|
40 |
+
base_url="https://api.siliconflow.cn/v1" # 使用第三方 硅基流动 API
|
41 |
+
)
|
42 |
+
else:
|
43 |
+
client = OpenAI(
|
44 |
+
api_key=api_key or os.getenv('OPENAI_API_KEY'),
|
45 |
+
base_url=base_url
|
46 |
+
)
|
47 |
+
|
48 |
+
messages = [
|
49 |
+
{
|
50 |
+
"role": "system",
|
51 |
+
"content": """你是一名经验丰富的短剧编剧,擅长根据字幕内容按照先后顺序分析关键剧情,并找出 %s 个关键片段。
|
52 |
+
请返回一个JSON对象,包含以下字段:
|
53 |
+
{
|
54 |
+
"summary": "整体剧情梗概",
|
55 |
+
"plot_titles": [
|
56 |
+
"关键剧情1",
|
57 |
+
"关键剧情2",
|
58 |
+
"关键剧情3",
|
59 |
+
"关键剧情4",
|
60 |
+
"关键剧情5",
|
61 |
+
"..."
|
62 |
+
]
|
63 |
+
}
|
64 |
+
请确保返回的是合法的JSON格式, 请确保返回的是 %s 个片段。
|
65 |
+
""" % (custom_clips, custom_clips)
|
66 |
+
},
|
67 |
+
{
|
68 |
+
"role": "user",
|
69 |
+
"content": f"srt字幕如下:{subtitle_content}"
|
70 |
+
}
|
71 |
+
]
|
72 |
+
# DeepSeek R1 和 V3 不支持 response_format=json_object
|
73 |
+
try:
|
74 |
+
completion = client.chat.completions.create(
|
75 |
+
model=model_name,
|
76 |
+
messages=messages,
|
77 |
+
response_format={"type": "json_object"}
|
78 |
+
)
|
79 |
+
summary_data = json.loads(completion.choices[0].message.content)
|
80 |
+
except BadRequestError as e:
|
81 |
+
completion = client.chat.completions.create(
|
82 |
+
model=model_name,
|
83 |
+
messages=messages
|
84 |
+
)
|
85 |
+
# 去除 completion 字符串前的 ```json 和 结尾的 ```
|
86 |
+
completion = completion.choices[0].message.content.replace("```json", "").replace("```", "")
|
87 |
+
summary_data = json.loads(completion)
|
88 |
+
except Exception as e:
|
89 |
+
raise Exception(f"大模型解析发生错误:{str(e)}\n{traceback.format_exc()}")
|
90 |
+
|
91 |
+
print(json.dumps(summary_data, indent=4, ensure_ascii=False))
|
92 |
+
|
93 |
+
# 获取爆点时间段分析
|
94 |
+
prompt = f"""剧情梗概:
|
95 |
+
{summary_data['summary']}
|
96 |
+
|
97 |
+
需要定位的爆点内容:
|
98 |
+
"""
|
99 |
+
print(f"找到 {len(summary_data['plot_titles'])} 个片段")
|
100 |
+
for i, point in enumerate(summary_data['plot_titles'], 1):
|
101 |
+
prompt += f"{i}. {point}\n"
|
102 |
+
|
103 |
+
messages = [
|
104 |
+
{
|
105 |
+
"role": "system",
|
106 |
+
"content": """你是一名短剧编剧,非常擅长根据字幕中分析视频中关键剧情出现的具体时间段。
|
107 |
+
请仔细阅读剧情梗概和爆点内容,然后在字幕中找出每个爆点发生的具体时间段和爆点前后的详细剧情。
|
108 |
+
|
109 |
+
请返回一个JSON对象,包含一个名为"plot_points"的数组,数组中包含多个对象,每个对象都要包含以下字段:
|
110 |
+
{
|
111 |
+
"plot_points": [
|
112 |
+
{
|
113 |
+
"timestamp": "时间段,格式为xx:xx:xx,xxx-xx:xx:xx,xxx",
|
114 |
+
"title": "关键剧情的主题",
|
115 |
+
"picture": "关键剧情前后的详细剧情描述"
|
116 |
+
}
|
117 |
+
]
|
118 |
+
}
|
119 |
+
请确保返回的是合法的JSON格式。"""
|
120 |
+
},
|
121 |
+
{
|
122 |
+
"role": "user",
|
123 |
+
"content": f"""字幕内容:
|
124 |
+
{subtitle_content}
|
125 |
+
|
126 |
+
{prompt}"""
|
127 |
+
}
|
128 |
+
]
|
129 |
+
# DeepSeek R1 和 V3 不支持 response_format=json_object
|
130 |
+
try:
|
131 |
+
completion = client.chat.completions.create(
|
132 |
+
model=model_name,
|
133 |
+
messages=messages,
|
134 |
+
response_format={"type": "json_object"}
|
135 |
+
)
|
136 |
+
plot_points_data = json.loads(completion.choices[0].message.content)
|
137 |
+
except BadRequestError as e:
|
138 |
+
completion = client.chat.completions.create(
|
139 |
+
model=model_name,
|
140 |
+
messages=messages
|
141 |
+
)
|
142 |
+
# 去除 completion 字符串前的 ```json 和 结尾的 ```
|
143 |
+
completion = completion.choices[0].message.content.replace("```json", "").replace("```", "")
|
144 |
+
plot_points_data = json.loads(completion)
|
145 |
+
except Exception as e:
|
146 |
+
raise Exception(f"大模型解析错误:{str(e)}\n{traceback.format_exc()}")
|
147 |
+
|
148 |
+
print(json.dumps(plot_points_data, indent=4, ensure_ascii=False))
|
149 |
+
|
150 |
+
# 合并结果
|
151 |
+
return {
|
152 |
+
"plot_summary": summary_data,
|
153 |
+
"plot_points": plot_points_data["plot_points"]
|
154 |
+
}
|
155 |
+
|
156 |
+
except Exception as e:
|
157 |
+
raise Exception(f"分析字幕时发生错误:{str(e)}\n{traceback.format_exc()}")
|
app/services/SDP/utils/step5_merge_script.py
ADDED
@@ -0,0 +1,69 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"""
|
2 |
+
合并生成最终脚本
|
3 |
+
"""
|
4 |
+
import os
|
5 |
+
import json
|
6 |
+
from typing import List, Dict, Tuple
|
7 |
+
|
8 |
+
|
9 |
+
def merge_script(
|
10 |
+
plot_points: List[Dict],
|
11 |
+
output_path: str
|
12 |
+
):
|
13 |
+
"""合并生成最终脚本
|
14 |
+
|
15 |
+
Args:
|
16 |
+
plot_points: 校对后的剧情点
|
17 |
+
output_path: 输出文件路径,如果提供则保存到文件
|
18 |
+
|
19 |
+
Returns:
|
20 |
+
str: 最终合并的脚本
|
21 |
+
"""
|
22 |
+
def parse_timestamp(ts: str) -> Tuple[float, float]:
|
23 |
+
"""解析时间戳,返回开始和结束时间(秒)"""
|
24 |
+
start, end = ts.split('-')
|
25 |
+
|
26 |
+
def parse_time(time_str: str) -> float:
|
27 |
+
time_str = time_str.strip()
|
28 |
+
if ',' in time_str:
|
29 |
+
time_parts, ms_parts = time_str.split(',')
|
30 |
+
ms = float(ms_parts) / 1000
|
31 |
+
else:
|
32 |
+
time_parts = time_str
|
33 |
+
ms = 0
|
34 |
+
|
35 |
+
hours, minutes, seconds = map(int, time_parts.split(':'))
|
36 |
+
return hours * 3600 + minutes * 60 + seconds + ms
|
37 |
+
|
38 |
+
return parse_time(start), parse_time(end)
|
39 |
+
|
40 |
+
def format_timestamp(seconds: float) -> str:
|
41 |
+
"""将秒数转换为时间戳格式 HH:MM:SS"""
|
42 |
+
hours = int(seconds // 3600)
|
43 |
+
minutes = int((seconds % 3600) // 60)
|
44 |
+
secs = int(seconds % 60)
|
45 |
+
return f"{hours:02d}:{minutes:02d}:{secs:02d}"
|
46 |
+
|
47 |
+
# 创建包含所有信息的临时列表
|
48 |
+
final_script = []
|
49 |
+
|
50 |
+
# 处理原生画面条目
|
51 |
+
number = 1
|
52 |
+
for plot_point in plot_points:
|
53 |
+
start, end = parse_timestamp(plot_point["timestamp"])
|
54 |
+
script_item = {
|
55 |
+
"_id": number,
|
56 |
+
"timestamp": plot_point["timestamp"],
|
57 |
+
"picture": plot_point["picture"],
|
58 |
+
"narration": f"播放原生_{os.urandom(4).hex()}",
|
59 |
+
"OST": 1, # OST=0 仅保留解说 OST=2 保留解说和原声
|
60 |
+
}
|
61 |
+
final_script.append(script_item)
|
62 |
+
number += 1
|
63 |
+
|
64 |
+
# 保存结果
|
65 |
+
with open(output_path, 'w', encoding='utf-8') as f:
|
66 |
+
json.dump(final_script, f, ensure_ascii=False, indent=4)
|
67 |
+
|
68 |
+
print(f"脚本生成完成:{output_path}")
|
69 |
+
return final_script
|
app/services/SDP/utils/utils.py
ADDED
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# 公共方法
|
2 |
+
import json
|
3 |
+
import requests # 新增
|
4 |
+
from typing import List, Dict
|
5 |
+
|
6 |
+
|
7 |
+
def load_srt(file_path: str) -> List[Dict]:
|
8 |
+
"""加载并解析SRT文件
|
9 |
+
|
10 |
+
Args:
|
11 |
+
file_path: SRT文件路径
|
12 |
+
|
13 |
+
Returns:
|
14 |
+
字幕内容列表
|
15 |
+
"""
|
16 |
+
with open(file_path, 'r', encoding='utf-8-sig') as f:
|
17 |
+
content = f.read().strip()
|
18 |
+
|
19 |
+
# 按空行分割字幕块
|
20 |
+
subtitle_blocks = content.split('\n\n')
|
21 |
+
subtitles = []
|
22 |
+
|
23 |
+
for block in subtitle_blocks:
|
24 |
+
lines = block.split('\n')
|
25 |
+
if len(lines) >= 3: # 确保块包含足够的行
|
26 |
+
try:
|
27 |
+
number = int(lines[0].strip())
|
28 |
+
timestamp = lines[1]
|
29 |
+
text = ' '.join(lines[2:])
|
30 |
+
|
31 |
+
# 解析时间戳
|
32 |
+
start_time, end_time = timestamp.split(' --> ')
|
33 |
+
|
34 |
+
subtitles.append({
|
35 |
+
'number': number,
|
36 |
+
'timestamp': timestamp,
|
37 |
+
'text': text,
|
38 |
+
'start_time': start_time,
|
39 |
+
'end_time': end_time
|
40 |
+
})
|
41 |
+
except ValueError as e:
|
42 |
+
print(f"Warning: 跳过无效的字幕块: {e}")
|
43 |
+
continue
|
44 |
+
|
45 |
+
return subtitles
|
app/services/__init__.py
ADDED
File without changes
|
app/services/audio_merger.py
ADDED
@@ -0,0 +1,171 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import os
|
2 |
+
import json
|
3 |
+
import subprocess
|
4 |
+
import edge_tts
|
5 |
+
from edge_tts import submaker
|
6 |
+
from pydub import AudioSegment
|
7 |
+
from typing import List, Dict
|
8 |
+
from loguru import logger
|
9 |
+
from app.utils import utils
|
10 |
+
|
11 |
+
|
12 |
+
def check_ffmpeg():
|
13 |
+
"""检查FFmpeg是否已安装"""
|
14 |
+
try:
|
15 |
+
subprocess.run(['ffmpeg', '-version'], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
16 |
+
return True
|
17 |
+
except FileNotFoundError:
|
18 |
+
return False
|
19 |
+
|
20 |
+
|
21 |
+
def merge_audio_files(task_id: str, total_duration: float, list_script: list):
|
22 |
+
"""
|
23 |
+
合并音频文件
|
24 |
+
|
25 |
+
Args:
|
26 |
+
task_id: 任务ID
|
27 |
+
total_duration: 总时长
|
28 |
+
list_script: 完整脚本信息,包含duration时长和audio路径
|
29 |
+
|
30 |
+
Returns:
|
31 |
+
str: 合并后的音频文件路径
|
32 |
+
"""
|
33 |
+
# 检查FFmpeg是否安装
|
34 |
+
if not check_ffmpeg():
|
35 |
+
logger.error("FFmpeg未安装,无法合并音频文件")
|
36 |
+
return None
|
37 |
+
|
38 |
+
# 创建一个空的音频片段
|
39 |
+
final_audio = AudioSegment.silent(duration=total_duration * 1000) # 总时长以毫秒为单位
|
40 |
+
|
41 |
+
# 计算每个片段的开始位置(基于duration字段)
|
42 |
+
current_position = 0 # 初始位置(秒)
|
43 |
+
|
44 |
+
# 遍历脚本中的每个片段
|
45 |
+
for segment in list_script:
|
46 |
+
try:
|
47 |
+
# 获取片段时长(秒)
|
48 |
+
duration = segment['duration']
|
49 |
+
|
50 |
+
# 检查audio字段是否为空
|
51 |
+
if segment['audio'] and os.path.exists(segment['audio']):
|
52 |
+
# 加载TTS音频文件
|
53 |
+
tts_audio = AudioSegment.from_file(segment['audio'])
|
54 |
+
|
55 |
+
# 将TTS音频添加到最终音频
|
56 |
+
final_audio = final_audio.overlay(tts_audio, position=current_position * 1000)
|
57 |
+
else:
|
58 |
+
# audio为空,不添加音频,仅保留间隔
|
59 |
+
logger.info(f"片段 {segment.get('timestamp', '')} 没有音频文件,保留 {duration} 秒的间隔")
|
60 |
+
|
61 |
+
# 更新下一个片段的开始位置
|
62 |
+
current_position += duration
|
63 |
+
|
64 |
+
except Exception as e:
|
65 |
+
logger.error(f"处理音频片段时出错: {str(e)}")
|
66 |
+
# 即使处理失败,也要更新位置,确保后续片段位置正确
|
67 |
+
if 'duration' in segment:
|
68 |
+
current_position += segment['duration']
|
69 |
+
continue
|
70 |
+
|
71 |
+
# 保存合并后的音频文件
|
72 |
+
output_audio_path = os.path.join(utils.task_dir(task_id), "merger_audio.mp3")
|
73 |
+
final_audio.export(output_audio_path, format="mp3")
|
74 |
+
logger.info(f"合并后的音频文件已保存: {output_audio_path}")
|
75 |
+
|
76 |
+
return output_audio_path
|
77 |
+
|
78 |
+
|
79 |
+
def time_to_seconds(time_str):
|
80 |
+
"""
|
81 |
+
将时间字符串转换为秒数,支持多种格式:
|
82 |
+
1. 'HH:MM:SS,mmm' (时:分:秒,毫秒)
|
83 |
+
2. 'MM:SS,mmm' (分:秒,毫秒)
|
84 |
+
3. 'SS,mmm' (秒,毫秒)
|
85 |
+
"""
|
86 |
+
try:
|
87 |
+
# 处理毫秒部分
|
88 |
+
if ',' in time_str:
|
89 |
+
time_part, ms_part = time_str.split(',')
|
90 |
+
ms = float(ms_part) / 1000
|
91 |
+
else:
|
92 |
+
time_part = time_str
|
93 |
+
ms = 0
|
94 |
+
|
95 |
+
# 分割时间部分
|
96 |
+
parts = time_part.split(':')
|
97 |
+
|
98 |
+
if len(parts) == 3: # HH:MM:SS
|
99 |
+
h, m, s = map(int, parts)
|
100 |
+
seconds = h * 3600 + m * 60 + s
|
101 |
+
elif len(parts) == 2: # MM:SS
|
102 |
+
m, s = map(int, parts)
|
103 |
+
seconds = m * 60 + s
|
104 |
+
else: # SS
|
105 |
+
seconds = int(parts[0])
|
106 |
+
|
107 |
+
return seconds + ms
|
108 |
+
except (ValueError, IndexError) as e:
|
109 |
+
logger.error(f"Error parsing time {time_str}: {str(e)}")
|
110 |
+
return 0.0
|
111 |
+
|
112 |
+
|
113 |
+
def extract_timestamp(filename):
|
114 |
+
"""
|
115 |
+
从文件名中提取开始和结束时间戳
|
116 |
+
例如: "audio_00_06,500-00_24,800.mp3" -> (6.5, 24.8)
|
117 |
+
"""
|
118 |
+
try:
|
119 |
+
# 从文件名中提取时间部分
|
120 |
+
time_part = filename.split('_', 1)[1].split('.')[0] # 获取 "00_06,500-00_24,800" 部分
|
121 |
+
start_time, end_time = time_part.split('-') # 分割成开始和结束时间
|
122 |
+
|
123 |
+
# 将下划线格式转换回冒号格式
|
124 |
+
start_time = start_time.replace('_', ':')
|
125 |
+
end_time = end_time.replace('_', ':')
|
126 |
+
|
127 |
+
# 将时间戳转换为秒
|
128 |
+
start_seconds = time_to_seconds(start_time)
|
129 |
+
end_seconds = time_to_seconds(end_time)
|
130 |
+
|
131 |
+
return start_seconds, end_seconds
|
132 |
+
except Exception as e:
|
133 |
+
logger.error(f"Error extracting timestamp from {filename}: {str(e)}")
|
134 |
+
return 0.0, 0.0
|
135 |
+
|
136 |
+
|
137 |
+
if __name__ == "__main__":
|
138 |
+
# 示例用法
|
139 |
+
total_duration = 90
|
140 |
+
|
141 |
+
video_script = [
|
142 |
+
{'picture': '【解说】好的,各位,欢迎回到我的频道!《庆余年 2》刚开播就给了我们一个王炸!范闲在北齐"死"了?这怎么可能!',
|
143 |
+
'timestamp': '00:00:00-00:00:26',
|
144 |
+
'narration': '好的各位,欢迎回到我的频道!《庆余年 2》刚开播就给了我们一个王炸!范闲在北齐"死"了?这怎么可能!上集片尾那个巨大的��念,这一集就立刻揭晓了!范闲假死归来,他面临的第一个,也是最大的难关,就是如何面对他最敬爱的,同时也是最可怕的那个人——庆帝!',
|
145 |
+
'OST': 0, 'duration': 26,
|
146 |
+
'audio': '/Users/apple/Desktop/home/NarratoAI/storage/tasks/qyn2-2-demo/audio_00_00_00-00_01_15.mp3'},
|
147 |
+
{'picture': '【解说】上一集我们看到,范闲在北齐遭遇了惊天变故,生死不明!', 'timestamp': '00:01:15-00:01:29',
|
148 |
+
'narration': '但我们都知道,他绝不可能就这么轻易退场!第二集一开场,范闲就已经秘密回到了京都。他的生死传闻,可不像我们想象中那样只是小范围流传,而是…',
|
149 |
+
'OST': 0, 'duration': 14,
|
150 |
+
'audio': '/Users/apple/Desktop/home/NarratoAI/storage/tasks/qyn2-2-demo/audio_00_01_15-00_04_40.mp3'},
|
151 |
+
{'picture': '画面切到王启年小心翼翼地向范闲汇报。', 'timestamp': '00:04:41-00:04:58',
|
152 |
+
'narration': '我发现大人的死讯不光是在民间,在官场上也它传开了,所以呢,所以啊,可不是什么好事,将来您跟陛下怎么交代,这可是欺君之罪',
|
153 |
+
'OST': 1, 'duration': 17,
|
154 |
+
'audio': ''},
|
155 |
+
{'picture': '【解说】"欺君之罪"!在封建王朝,这可是抄家灭族的大罪!搁一般人,肯定脚底抹油溜之大吉了。',
|
156 |
+
'timestamp': '00:04:58-00:05:20',
|
157 |
+
'narration': '"欺君之罪"!在封建王朝,这可是抄家灭族的大罪!搁一般人,肯定脚底抹油溜之大吉了。但范闲是谁啊?他偏要反其道而行之!他竟然决定,直接去见庆帝!冒着天大的风险,用"假死"这个事实去赌庆帝的态度!',
|
158 |
+
'OST': 0, 'duration': 22,
|
159 |
+
'audio': '/Users/apple/Desktop/home/NarratoAI/storage/tasks/qyn2-2-demo/audio_00_04_58-00_05_45.mp3'},
|
160 |
+
{'picture': '【解说】但想见庆帝,哪有那么容易?范闲艺高人胆大,竟然选择了最激进的方式——闯宫!',
|
161 |
+
'timestamp': '00:05:45-00:05:53',
|
162 |
+
'narration': '但想见庆帝,哪有那么容易?范闲艺高人胆大,竟然选择了最激进的方式——闯宫!',
|
163 |
+
'OST': 0, 'duration': 8,
|
164 |
+
'audio': '/Users/apple/Desktop/home/NarratoAI/storage/tasks/qyn2-2-demo/audio_00_05_45-00_06_00.mp3'},
|
165 |
+
{'picture': '画面切换到范闲蒙面闯入皇宫,被侍卫包围的场景。', 'timestamp': '00:06:00-00:06:03',
|
166 |
+
'narration': '抓刺客',
|
167 |
+
'OST': 1, 'duration': 3,
|
168 |
+
'audio': ''}]
|
169 |
+
|
170 |
+
output_file = merge_audio_files("test456", total_duration, video_script)
|
171 |
+
print(output_file)
|
app/services/clip_video.py
ADDED
@@ -0,0 +1,237 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
#!/usr/bin/env python
|
2 |
+
# -*- coding: UTF-8 -*-
|
3 |
+
|
4 |
+
'''
|
5 |
+
@Project: NarratoAI
|
6 |
+
@File : clip_video
|
7 |
+
@Author : 小林同学
|
8 |
+
@Date : 2025/5/6 下午6:14
|
9 |
+
'''
|
10 |
+
|
11 |
+
import os
|
12 |
+
import subprocess
|
13 |
+
import json
|
14 |
+
import hashlib
|
15 |
+
from loguru import logger
|
16 |
+
from typing import Dict, List, Optional
|
17 |
+
from pathlib import Path
|
18 |
+
|
19 |
+
from app.utils import ffmpeg_utils
|
20 |
+
|
21 |
+
|
22 |
+
def parse_timestamp(timestamp: str) -> tuple:
|
23 |
+
"""
|
24 |
+
解析时间戳字符串,返回开始和结束时间
|
25 |
+
|
26 |
+
Args:
|
27 |
+
timestamp: 格式为'HH:MM:SS-HH:MM:SS'或'HH:MM:SS,sss-HH:MM:SS,sss'的时间戳字符串
|
28 |
+
|
29 |
+
Returns:
|
30 |
+
tuple: (开始时间, 结束时间) 格式为'HH:MM:SS'或'HH:MM:SS,sss'
|
31 |
+
"""
|
32 |
+
start_time, end_time = timestamp.split('-')
|
33 |
+
return start_time, end_time
|
34 |
+
|
35 |
+
|
36 |
+
def calculate_end_time(start_time: str, duration: float, extra_seconds: float = 1.0) -> str:
|
37 |
+
"""
|
38 |
+
根据开始时间和持续时间计算结束时间
|
39 |
+
|
40 |
+
Args:
|
41 |
+
start_time: 开始时间,格式为'HH:MM:SS'或'HH:MM:SS,sss'(带毫秒)
|
42 |
+
duration: 持续时间,单位为秒
|
43 |
+
extra_seconds: 额外添加的秒数,默认为1秒
|
44 |
+
|
45 |
+
Returns:
|
46 |
+
str: 计算后的结束时间,格式与输入格式相同
|
47 |
+
"""
|
48 |
+
# 检查是否包含毫秒
|
49 |
+
has_milliseconds = ',' in start_time
|
50 |
+
milliseconds = 0
|
51 |
+
|
52 |
+
if has_milliseconds:
|
53 |
+
time_part, ms_part = start_time.split(',')
|
54 |
+
h, m, s = map(int, time_part.split(':'))
|
55 |
+
milliseconds = int(ms_part)
|
56 |
+
else:
|
57 |
+
h, m, s = map(int, start_time.split(':'))
|
58 |
+
|
59 |
+
# 转换为总毫秒数
|
60 |
+
total_milliseconds = ((h * 3600 + m * 60 + s) * 1000 + milliseconds +
|
61 |
+
int((duration + extra_seconds) * 1000))
|
62 |
+
|
63 |
+
# 计算新的时、分、秒、毫秒
|
64 |
+
ms_new = total_milliseconds % 1000
|
65 |
+
total_seconds = total_milliseconds // 1000
|
66 |
+
h_new = int(total_seconds // 3600)
|
67 |
+
m_new = int((total_seconds % 3600) // 60)
|
68 |
+
s_new = int(total_seconds % 60)
|
69 |
+
|
70 |
+
# 返回与输入格式一致的时间字符串
|
71 |
+
if has_milliseconds:
|
72 |
+
return f"{h_new:02d}:{m_new:02d}:{s_new:02d},{ms_new:03d}"
|
73 |
+
else:
|
74 |
+
return f"{h_new:02d}:{m_new:02d}:{s_new:02d}"
|
75 |
+
|
76 |
+
|
77 |
+
def check_hardware_acceleration() -> Optional[str]:
|
78 |
+
"""
|
79 |
+
检查系统支持的硬件加速选项
|
80 |
+
|
81 |
+
Returns:
|
82 |
+
Optional[str]: 硬件加速参数,如果不支持则返回None
|
83 |
+
"""
|
84 |
+
# 使用集中式硬件加速检测
|
85 |
+
return ffmpeg_utils.get_ffmpeg_hwaccel_type()
|
86 |
+
|
87 |
+
|
88 |
+
def clip_video(
|
89 |
+
video_origin_path: str,
|
90 |
+
tts_result: List[Dict],
|
91 |
+
output_dir: Optional[str] = None,
|
92 |
+
task_id: Optional[str] = None
|
93 |
+
) -> Dict[str, str]:
|
94 |
+
"""
|
95 |
+
根据时间戳裁剪视频
|
96 |
+
|
97 |
+
Args:
|
98 |
+
video_origin_path: 原始视频的路径
|
99 |
+
tts_result: 包含时间戳和持续时间信息的列表
|
100 |
+
output_dir: 输出目录路径,默认为None时会自动生成
|
101 |
+
task_id: 任务ID,用于生成唯一的输出目录,默认为None时会自动生成
|
102 |
+
|
103 |
+
Returns:
|
104 |
+
Dict[str, str]: 时间戳到裁剪后视频路径的映射
|
105 |
+
"""
|
106 |
+
# 检查视频文件是否存在
|
107 |
+
if not os.path.exists(video_origin_path):
|
108 |
+
raise FileNotFoundError(f"视频文件不存在: {video_origin_path}")
|
109 |
+
|
110 |
+
# 如果未提供task_id,则根据输入生成一个唯一ID
|
111 |
+
if task_id is None:
|
112 |
+
content_for_hash = f"{video_origin_path}_{json.dumps(tts_result)}"
|
113 |
+
task_id = hashlib.md5(content_for_hash.encode()).hexdigest()
|
114 |
+
|
115 |
+
# 设置输出目录
|
116 |
+
if output_dir is None:
|
117 |
+
output_dir = os.path.join(
|
118 |
+
os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
|
119 |
+
"storage", "temp", "clip_video", task_id
|
120 |
+
)
|
121 |
+
|
122 |
+
# 确保输出目录存在
|
123 |
+
Path(output_dir).mkdir(parents=True, exist_ok=True)
|
124 |
+
|
125 |
+
# 获取硬件加速支持
|
126 |
+
hwaccel = check_hardware_acceleration()
|
127 |
+
hwaccel_args = []
|
128 |
+
if hwaccel:
|
129 |
+
hwaccel_args = ffmpeg_utils.get_ffmpeg_hwaccel_args()
|
130 |
+
|
131 |
+
# 存储裁剪结果
|
132 |
+
result = {}
|
133 |
+
|
134 |
+
for item in tts_result:
|
135 |
+
_id = item.get("_id", item.get("timestamp", "unknown"))
|
136 |
+
timestamp = item["timestamp"]
|
137 |
+
start_time, _ = parse_timestamp(timestamp)
|
138 |
+
|
139 |
+
# 根据持续时间计算真正的结束时间(加上1秒余量)
|
140 |
+
duration = item["duration"]
|
141 |
+
calculated_end_time = calculate_end_time(start_time, duration)
|
142 |
+
|
143 |
+
# 转换为FFmpeg兼容的时间格式(逗号替换为点)
|
144 |
+
ffmpeg_start_time = start_time.replace(',', '.')
|
145 |
+
ffmpeg_end_time = calculated_end_time.replace(',', '.')
|
146 |
+
|
147 |
+
# 格式化输出文件名(使用连字符替代冒号和逗号)
|
148 |
+
safe_start_time = start_time.replace(':', '-').replace(',', '-')
|
149 |
+
safe_end_time = calculated_end_time.replace(':', '-').replace(',', '-')
|
150 |
+
output_filename = f"vid_{safe_start_time}@{safe_end_time}.mp4"
|
151 |
+
output_path = os.path.join(output_dir, output_filename)
|
152 |
+
|
153 |
+
# 构建FFmpeg命令
|
154 |
+
ffmpeg_cmd = [
|
155 |
+
"ffmpeg", "-y", *hwaccel_args,
|
156 |
+
"-i", video_origin_path,
|
157 |
+
"-ss", ffmpeg_start_time,
|
158 |
+
"-to", ffmpeg_end_time,
|
159 |
+
"-c:v", "h264_videotoolbox" if hwaccel == "videotoolbox" else "libx264",
|
160 |
+
"-c:a", "aac",
|
161 |
+
"-strict", "experimental",
|
162 |
+
output_path
|
163 |
+
]
|
164 |
+
|
165 |
+
# 执行FFmpeg命令
|
166 |
+
try:
|
167 |
+
logger.info(f"裁剪视频片段: {timestamp} -> {ffmpeg_start_time}到{ffmpeg_end_time}")
|
168 |
+
# logger.debug(f"执行命令: {' '.join(ffmpeg_cmd)}")
|
169 |
+
|
170 |
+
# 在Windows系统上使用UTF-8编码处理输出,避免GBK编码错误
|
171 |
+
is_windows = os.name == 'nt'
|
172 |
+
if is_windows:
|
173 |
+
process = subprocess.run(
|
174 |
+
ffmpeg_cmd,
|
175 |
+
stdout=subprocess.PIPE,
|
176 |
+
stderr=subprocess.PIPE,
|
177 |
+
encoding='utf-8', # 明确指定编码为UTF-8
|
178 |
+
text=True,
|
179 |
+
check=True
|
180 |
+
)
|
181 |
+
else:
|
182 |
+
process = subprocess.run(
|
183 |
+
ffmpeg_cmd,
|
184 |
+
stdout=subprocess.PIPE,
|
185 |
+
stderr=subprocess.PIPE,
|
186 |
+
text=True,
|
187 |
+
check=True
|
188 |
+
)
|
189 |
+
|
190 |
+
result[_id] = output_path
|
191 |
+
|
192 |
+
except subprocess.CalledProcessError as e:
|
193 |
+
logger.error(f"裁剪视频片段失败: {timestamp}")
|
194 |
+
logger.error(f"错误信息: {e.stderr}")
|
195 |
+
raise RuntimeError(f"视频裁剪失败: {e.stderr}")
|
196 |
+
|
197 |
+
return result
|
198 |
+
|
199 |
+
|
200 |
+
if __name__ == "__main__":
|
201 |
+
video_origin_path = "/Users/apple/Desktop/home/NarratoAI/resource/videos/qyn2-2无片头片尾.mp4"
|
202 |
+
|
203 |
+
tts_result = [{'timestamp': '00:00:00-00:01:15',
|
204 |
+
'audio_file': '/Users/apple/Desktop/home/NarratoAI/storage/tasks/qyn2-2-demo/audio_00_00_00-00_01_15.mp3',
|
205 |
+
'subtitle_file': '/Users/apple/Desktop/home/NarratoAI/storage/tasks/qyn2-2-demo/subtitle_00_00_00-00_01_15.srt',
|
206 |
+
'duration': 25.55,
|
207 |
+
'text': '好的各位,欢迎回到我的频道!《庆余年 2》刚开播就给了我们一个王炸!范闲在北齐"死"了?这怎么可能!上集片尾那个巨大的悬念,这一集就立刻揭晓了!范闲假死归来,他面临的第一个,也是最大的难关,就是如何面对他最敬爱的,同时也是最可怕的那个人——庆帝!'},
|
208 |
+
{'timestamp': '00:01:15-00:04:40',
|
209 |
+
'audio_file': '/Users/apple/Desktop/home/NarratoAI/storage/tasks/qyn2-2-demo/audio_00_01_15-00_04_40.mp3',
|
210 |
+
'subtitle_file': '/Users/apple/Desktop/home/NarratoAI/storage/tasks/qyn2-2-demo/subtitle_00_01_15-00_04_40.srt',
|
211 |
+
'duration': 13.488,
|
212 |
+
'text': '但我们都知道,他绝不可能就这么轻易退场!第二集一开场,范闲就已经秘密回到了京都。他的生死传闻,可不像我们想象中那样只是小范围流传,而是…'},
|
213 |
+
{'timestamp': '00:04:58-00:05:45',
|
214 |
+
'audio_file': '/Users/apple/Desktop/home/NarratoAI/storage/tasks/qyn2-2-demo/audio_00_04_58-00_05_45.mp3',
|
215 |
+
'subtitle_file': '/Users/apple/Desktop/home/NarratoAI/storage/tasks/qyn2-2-demo/subtitle_00_04_58-00_05_45.srt',
|
216 |
+
'duration': 21.363,
|
217 |
+
'text': '"欺君之罪"!在封建王朝,这可是抄家灭族的大罪!搁一般人,肯定脚底抹油溜之大吉了。但范闲是谁啊?他偏要反其道而行之!他竟然决定,直接去见庆帝!冒着天大的风险,用"假死"这个事实去赌庆帝的态度!'},
|
218 |
+
{'timestamp': '00:05:45-00:06:00',
|
219 |
+
'audio_file': '/Users/apple/Desktop/home/NarratoAI/storage/tasks/qyn2-2-demo/audio_00_05_45-00_06_00.mp3',
|
220 |
+
'subtitle_file': '/Users/apple/Desktop/home/NarratoAI/storage/tasks/qyn2-2-demo/subtitle_00_05_45-00_06_00.srt',
|
221 |
+
'duration': 7.675, 'text': '但想见庆帝,哪有那么容易?范闲艺高人胆大,竟然选择了最激进的方式——闯宫!'}]
|
222 |
+
subclip_path_videos = {
|
223 |
+
'00:00:00-00:01:15': '/Users/apple/Desktop/home/NarratoAI/storage/temp/clip_video/6e7e343c7592c7d6f9a9636b55000f23/vid-00-00-00-00-01-15.mp4',
|
224 |
+
'00:01:15-00:04:40': '/Users/apple/Desktop/home/NarratoAI/storage/temp/clip_video/6e7e343c7592c7d6f9a9636b55000f23/vid-00-01-15-00-04-40.mp4',
|
225 |
+
'00:04:41-00:04:58': '/Users/apple/Desktop/home/NarratoAI/storage/temp/clip_video/6e7e343c7592c7d6f9a9636b55000f23/vid-00-04-41-00-04-58.mp4',
|
226 |
+
'00:04:58-00:05:45': '/Users/apple/Desktop/home/NarratoAI/storage/temp/clip_video/6e7e343c7592c7d6f9a9636b55000f23/vid-00-04-58-00-05-45.mp4',
|
227 |
+
'00:05:45-00:06:00': '/Users/apple/Desktop/home/NarratoAI/storage/temp/clip_video/6e7e343c7592c7d6f9a9636b55000f23/vid-00-05-45-00-06-00.mp4',
|
228 |
+
'00:06:00-00:06:03': '/Users/apple/Desktop/home/NarratoAI/storage/temp/clip_video/6e7e343c7592c7d6f9a9636b55000f23/vid-00-06-00-00-06-03.mp4',
|
229 |
+
}
|
230 |
+
|
231 |
+
# 使用方法示例
|
232 |
+
try:
|
233 |
+
result = clip_video(video_origin_path, tts_result, subclip_path_videos)
|
234 |
+
print("裁剪结果:")
|
235 |
+
print(json.dumps(result, indent=4, ensure_ascii=False))
|
236 |
+
except Exception as e:
|
237 |
+
print(f"发生错误: {e}")
|
app/services/generate_narration_script.py
ADDED
@@ -0,0 +1,264 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
#!/usr/bin/env python
|
2 |
+
# -*- coding: UTF-8 -*-
|
3 |
+
|
4 |
+
'''
|
5 |
+
@Project: NarratoAI
|
6 |
+
@File : 生成介绍文案
|
7 |
+
@Author : 小林同学
|
8 |
+
@Date : 2025/5/8 上午11:33
|
9 |
+
'''
|
10 |
+
|
11 |
+
import json
|
12 |
+
import os
|
13 |
+
import traceback
|
14 |
+
from openai import OpenAI
|
15 |
+
from loguru import logger
|
16 |
+
|
17 |
+
|
18 |
+
def parse_frame_analysis_to_markdown(json_file_path):
|
19 |
+
"""
|
20 |
+
解析视频帧分析JSON文件并转换为Markdown格式
|
21 |
+
|
22 |
+
:param json_file_path: JSON文件路径
|
23 |
+
:return: Markdown格式的字符串
|
24 |
+
"""
|
25 |
+
# 检查文件是否存在
|
26 |
+
if not os.path.exists(json_file_path):
|
27 |
+
return f"错误: 文件 {json_file_path} 不存在"
|
28 |
+
|
29 |
+
try:
|
30 |
+
# 读取JSON文件
|
31 |
+
with open(json_file_path, 'r', encoding='utf-8') as file:
|
32 |
+
data = json.load(file)
|
33 |
+
|
34 |
+
# 初始化Markdown字符串
|
35 |
+
markdown = ""
|
36 |
+
|
37 |
+
# 获取总结和帧观察数据
|
38 |
+
summaries = data.get('overall_activity_summaries', [])
|
39 |
+
frame_observations = data.get('frame_observations', [])
|
40 |
+
|
41 |
+
# 按批次组织数据
|
42 |
+
batch_frames = {}
|
43 |
+
for frame in frame_observations:
|
44 |
+
batch_index = frame.get('batch_index')
|
45 |
+
if batch_index not in batch_frames:
|
46 |
+
batch_frames[batch_index] = []
|
47 |
+
batch_frames[batch_index].append(frame)
|
48 |
+
|
49 |
+
# 生成Markdown内容
|
50 |
+
for i, summary in enumerate(summaries, 1):
|
51 |
+
batch_index = summary.get('batch_index')
|
52 |
+
time_range = summary.get('time_range', '')
|
53 |
+
batch_summary = summary.get('summary', '')
|
54 |
+
|
55 |
+
markdown += f"## 片段 {i}\n"
|
56 |
+
markdown += f"- 时间范围:{time_range}\n"
|
57 |
+
|
58 |
+
# 添加片段描述
|
59 |
+
markdown += f"- 片段描述:{batch_summary}\n" if batch_summary else f"- 片段描述:\n"
|
60 |
+
|
61 |
+
markdown += "- 详细描述:\n"
|
62 |
+
|
63 |
+
# 添加该批次的帧观察详情
|
64 |
+
frames = batch_frames.get(batch_index, [])
|
65 |
+
for frame in frames:
|
66 |
+
timestamp = frame.get('timestamp', '')
|
67 |
+
observation = frame.get('observation', '')
|
68 |
+
|
69 |
+
# 直接使用原始文本,不进行分割
|
70 |
+
markdown += f" - {timestamp}: {observation}\n" if observation else f" - {timestamp}: \n"
|
71 |
+
|
72 |
+
markdown += "\n"
|
73 |
+
|
74 |
+
return markdown
|
75 |
+
|
76 |
+
except Exception as e:
|
77 |
+
return f"处理JSON文件时出错: {traceback.format_exc()}"
|
78 |
+
|
79 |
+
|
80 |
+
def generate_narration(markdown_content, api_key, base_url, model):
|
81 |
+
"""
|
82 |
+
调用OpenAI API根据视频帧分析的Markdown内容生成解说文案
|
83 |
+
|
84 |
+
:param markdown_content: Markdown格式的视频帧分析内容
|
85 |
+
:param api_key: OpenAI API密钥
|
86 |
+
:param base_url: API基础URL,如果使用非官方API
|
87 |
+
:param model: 使用的模型名称
|
88 |
+
:return: 生成的解说文案
|
89 |
+
"""
|
90 |
+
try:
|
91 |
+
# 构建提示词
|
92 |
+
prompt = """
|
93 |
+
我是一名荒野建造解说的博主,以下是一些同行的对标文案,请你深度学习并总结这些文案的风格特点跟内容特点:
|
94 |
+
|
95 |
+
<example_text_1>
|
96 |
+
解压助眠的天花板就是荒野建造,沉浸丝滑的搭建过程可以说每一帧都是极致享受,我保证强迫症来了都找不出一丁点毛病。更别说全屋严丝合缝的拼接工艺,还能轻松抵御零下二十度气温,让你居住的每一天都温暖如春。
|
97 |
+
在家闲不住的西姆今天也打算来一次野外建造,行走没多久他就发现许多倒塌的树,任由它们自生自灭不如将其利用起来。想到这他就开始挥舞铲子要把地基挖掘出来,虽然每次只能挖一点点,但架不住他体能惊人。没多长时间一个 2x3 的深坑就赫然出现,这深度住他一人绰绰有余。
|
98 |
+
随后他去附近收集来原木,这些都是搭建墙壁的最好材料。而在投入使用前自然要把表皮刮掉,防止森林中的白蚁蛀虫。处理好一大堆后西姆还在两端打孔,使用木钉固定在一起。这可不是用来做墙壁的,而是做庇护所的承重柱。只要木头间的缝隙足够紧密,那搭建出的木屋就能足够坚固。
|
99 |
+
每向上搭建一层,他都会在中间塞入苔藓防寒,保证不会泄露一丝热量。其他几面也是用相同方法,很快西姆就做好了三面墙壁,每一根木头都极其工整,保证强迫症来了都要点个赞再走。
|
100 |
+
在继续搭建墙壁前西姆决定将壁炉制作出来,毕竟森林夜晚的气温会很低,保暖措施可是重中之重。完成后他找来一块大树皮用来充当庇护所的大门,而上面刮掉的木屑还能作为壁炉的引火物,可以说再完美不过。
|
101 |
+
测试了排烟没问题后他才开始搭建最后一面墙壁,这一面要预留门和窗,所以在搭建到一半后还需要在原木中间开出卡口,让自己劈砍时能轻松许多。此时只需将另外一根如法炮制,两端拼接在一起后就��一扇大小适中的窗户。而随着随后一层苔藓铺好,最后一根原木落位,这个庇护所的雏形就算完成。
|
102 |
+
大门的安装他没选择用合页,而是在底端雕刻出榫头,门框上则雕刻出榫眼,只能说西姆的眼就是一把尺,这完全就是严丝合缝。此时他才开始搭建屋顶。这里西姆用的方法不同,他先把最外围的原木固定好,随后将原木平铺在上面,就能得到完美的斜面屋顶。等他将四周的围栏也装好后,工整的屋顶看起来十分舒服,西姆躺上去都不想动。
|
103 |
+
稍作休息后,他利用剩余的苔藓,对屋顶的缝隙处密封。可这样西姆觉得不够保险,于是他找来一些黏土,再次对原本的缝隙二次加工,保管这庇护所冬天也暖和。最后只需要平铺上枯叶,以及挖掘出的泥土,整个屋顶就算完成。
|
104 |
+
考虑到庇护所的美观性,自然少不了覆盖上苔藓,翠绿的颜色看起来十分舒服。就连门口的庭院旁,他都移植了许多小树做点缀,让这木屋与周边环境融为一体。西姆才刚完成好这件事,一场大雨就骤然降临。好在此时的他已经不用淋雨,更别说这屋顶防水十分不错,室内没一点雨水渗透进来。
|
105 |
+
等待温度回升的过程,西姆利用墙壁本身的凹槽,把床框镶嵌在上面,只需要铺上苔藓,以及自带的床单枕头,一张完美的单人床就做好。辛苦劳作一整天,西姆可不会亏待自己。他将自带的牛肉腌制好后,直接放到壁炉中烤,只需要等待三十分钟,就能享受这美味的一顿。
|
106 |
+
在辛苦建造一星期后,他终于可以在自己搭建的庇护所中,享受最纯正的野外露营。后面西姆回家补给了一堆物资,再次回来时森林已经大雪纷飞,让他原本翠绿的小屋,更换上了冬季限定皮肤。好在内部设施没受什么影响,和他离开时一样整洁。
|
107 |
+
就是房间中已经没多少柴火,让西姆今天又得劈柴。寒冷干燥的天气,让木头劈起来十分轻松。没多久他就收集到一大堆,这些足够燃烧好几天。虽然此时外面大雪纷飞,但小屋中却开始逐渐温暖。这次他除了带来一些食物外,还有几瓶调味料,以及一整套被褥,让自己的居住舒适度提高一大截。
|
108 |
+
而秋天他有收集干草的缘故,只需要塞入枕套中密封起来,就能作为靠垫用。就这居住条件,比一般人在家过的还要奢侈。趁着壁炉木头变木炭的过程,西姆则开始不紧不慢的处理食物。他取出一块牛排,改好花刀以后,撒上一堆调料腌制起来。接着用锡纸包裹好,放到壁炉中直接炭烤,搭配上自带的红酒,是一个非常好的选择。
|
109 |
+
随着时间来到第二天,外面的积雪融化了不少,西姆简单做顿煎蛋补充体力后,决定制作一个室外篝火堆,用来晚上驱散周边野兽。搭建这玩意没什么技巧,只需要找到一大堆木棍,利用大树的夹缝将其掰弯,然后将其堆积在一起,就是一个简易版的篝火堆。看这外形有点像帐篷,好在西姆没想那么多。
|
110 |
+
等待天色暗淡下来后,他才来到室外将其点燃,顺便处理下多余的废料。只可惜这场景没朋友陪在身边,对西姆来说可能是个遗憾。而哪怕森林只有他一个人,都依旧做了好几个小时。等到里面的篝火彻底燃尽后,西姆还找来雪球,覆盖到上面将火熄灭,这防火意识可谓十分好。最后在室内二十五度的高温下,裹着被子睡觉。
|
111 |
+
</example_text_1>
|
112 |
+
|
113 |
+
<example_text_2>
|
114 |
+
解压助眠的天花板就是荒野建造,沉浸丝滑的搭建过程每一帧都是极致享受,全屋严丝合缝的拼接工艺,能轻松抵御零下二十度气温,居住体验温暖如春。
|
115 |
+
在家闲不住的西姆开启野外建造。他发现倒塌的树,决定加以利用。先挖掘出 2x3 的深坑作为地基,接着收集原木,刮掉表皮防白蚁蛀虫,打孔用木钉固定制作承重柱。搭建墙壁时,每一层都塞入苔藓防寒,很快做好三面墙。
|
116 |
+
为应对森林夜晚低温,西姆制作壁炉,用大树皮当大门,刮下的木屑做引火物。搭建最后一面墙时预留门窗,通过在原木中间开口拼接做出窗户。大门采用榫卯结构安装,严丝合缝。
|
117 |
+
搭建屋顶时,先固定外围原木,再平铺原木形成斜面屋顶,之后用苔藓、黏土密封缝隙,铺上枯叶和泥土。为美观,在木屋覆盖苔藓,移植小树点缀。完工时遇大雨,木屋防水良好。
|
118 |
+
西姆利用墙壁凹槽镶嵌床框,铺上苔藓、床单枕头做成床。劳作一天后,他用壁炉烤牛肉享用。建造一星期后,他开始野外露营。
|
119 |
+
后来西姆回家补给物资,回来时森林大雪纷飞。他劈柴储备,带回食物、调味料和被褥,提高居住舒适度,还用干草做靠垫。他用壁炉烤牛排,搭配红酒。
|
120 |
+
第二天,积雪融化,西姆制作室外篝火堆防野兽。用大树夹缝掰弯木棍堆积而成,晚上点燃处理废料,结束后用雪球灭火,最后在室内二十五度的环境中裹被入睡。
|
121 |
+
</example_text_2>
|
122 |
+
|
123 |
+
<example_text_3>
|
124 |
+
如果战争到来,这个深埋地下十几米的庇护所绝对是 bug 般的存在。即使被敌人发现,还能通过快速通道一秒逃出。里面不仅有竹子、地暖、地下水井,还自制抽水机。在解决用水问题的同时,甚至自研无土栽培技术,过上完全自给自足的生活。
|
125 |
+
阿伟的老婆美如花,但阿伟从来不回家,来到野外他乐哈哈,一言不合就开挖。众所周知当战争来临时,地下堡垒的安全性是最高的。阿伟苦苦研习两载半,只为练就一身挖洞本领。在这双逆天麒麟臂的加持下,如此坚硬的泥土都只能当做炮灰。
|
126 |
+
得到了充足的空间后,他便开始对这些边缘进行打磨。随后阿伟将细线捆在木棍上,以此描绘出圆柱的轮廓。接着再一点点铲掉多余的部分。虽然是由泥土一体式打造,但这样的桌子保准用上千年都不成问题。
|
127 |
+
考虑到十几米的深度进出非常不方便,于是阿伟找来两根长达 66.6 米的木头,打算为庇护所打造一条快速通道。只见他将木桩牢牢地插入地下,并顺着洞口的方向延伸出去,直到贯穿整个山洞。接着在每个木桩的连接处钉入铁钉,确保轨道不能有一毫米的偏差。完成后再制作一个木质框架,从而达到前后滑动的效果。
|
128 |
+
不得不说阿伟这手艺简直就是大钢管子杵青蛙。在上面放上一个木制的车斗,还能加快搬运泥土的速度。没多久庇护所的内部就已经初见雏形。为了住起来更加舒适,还需要为自己打造一张床。虽然深处的泥土同样很坚固,但好处就是不用担心垮塌的风险。
|
129 |
+
阿伟不仅设计了更加符合人体工学的拱形,并且还在一旁雕刻处壁龛。就是这氛围怎么看着有点不太吉利。别看阿伟一身腱子肉,但这身体里的艺术细菌可不少。每个边缘的地方他都做了精雕细琢,瞬间让整个卧室的颜值提升一大截。
|
130 |
+
住在地下的好处就是房子面积全靠挖,每平方消耗两个半馒头。不仅没有了房贷的压力,就连买墓地的钱也省了。阿伟将中间的墙壁挖空,从而得到取暖的壁炉。当然最重要的还有排烟问题,要想从上往下打通十几米的山体是件极其困难的事。好在阿伟年轻时报过忆坤年的古墓派补习班,这打洞技术堪比隔壁学校的土拨鼠专业。虽然深度长达十几米,但排烟效果却一点不受影响,一个字专业!
|
131 |
+
随后阿伟继续对壁炉底部雕刻,打通了底部放柴火的空间,并制作出放锅的灶头。完成后阿伟从侧面将壁炉打通,并制作出一条导热的通道,以此连接到床铺的位置。毕竟住在这么一个风湿宝地,不注意保暖除湿很容易得老寒腿。
|
132 |
+
阿伟在床面上挖出一条条管道,以便于温度能传输到床的每个角落。接下来就可以根据这些通道的长度裁切出同样长短的竹子,根据竹筒的大小凿出相互连接的孔洞,最后再将竹筒内部打通,以达到温度传送的效果。
|
133 |
+
而后阿伟将这些管道安装到凹槽内,在他严谨的制作工艺下,每根竹子刚好都能镶嵌进去。在铺设床面之前还需要用木塞把圆孔堵住,防止泥土掉落进管道。泥土虽然不能隔绝湿气,但却是十分优良的导热材料。等他把床面都压平后就可以小心的将这些木塞拔出来,最后再用黏土把剩余的管道也遮盖起来,直到整个墙面恢复原样。
|
134 |
+
接下来还需要测试一下加热效果,当他把火点起来后,温度很快就传送到了管道内,把火力一点点加大,直到热气流淌到更远的床面。随着小孔里的青烟冒出,也预示着阿伟的地暖可以投入使用。而后阿伟制作了一些竹条,并用细绳将它们喜结连理。
|
135 |
+
千里之行始于足下,美好的家园要靠自己双手打造。明明可以靠才艺吃饭的阿伟偏偏要用八块腹肌征服大家,就问这样的男人哪个野生婆娘不喜欢?完成后阿伟还用自己 35 码的大腚感受了一下,真烫!
|
136 |
+
随后阿伟来到野区找到一根上好的雷击木,他当即就把木头咔嚓成两段,并取下两节较为完整的带了回去,刚好能和圆桌配套。另外一个在里面凿出凹槽,并插入木棍连接,得到一个夯土的木锤。住过农村的小伙伴都知道,这样夯出来的地面堪比水泥地,不仅坚硬耐磨,还不用担心脚底打滑。忙碌了一天的阿伟已经饥渴难耐,拿出野生小烤肠,安安心心住新房,光脚爬上大热炕,一觉能睡到天亮。
|
137 |
+
第二天阿伟打算将房间扩宽,毕竟吃住的地方有了,还要解决个人卫生的问题。阿伟在另一侧增加了一个房间,他打算将这里打造成洗澡的地方。为了防止泥土垮塌,他将顶部做���圆弧形,等挖出足够的空间后,旁边的泥土已经堆成了小山。
|
138 |
+
为了方便清理这些泥土,阿伟在之前的轨道增加了转弯,交接处依然是用铁钉固定,一直延伸到房间的最里面。有了运输车的帮助,这些成吨的泥土也能轻松的运送出去,并且还能体验过山车的感觉。很快他就完成了清理工作。
|
139 |
+
为了更方便的在里面洗澡,他将底部一点点挖空,这么大的浴缸,看来阿伟并不打算一个人住。完成后他将墙面雕刻的凹凸有致,让这里看起来更加豪华。接着用洛阳铲挖出排水口,并用一根相同大小的竹筒作为开关。
|
140 |
+
由于四周都是泥土还不能防水,阿伟特意找了一些白蚁巢,用来制作可以防水的野生水泥。现在就可以将里里外外,能接触到水的地方都涂抹一遍。细心的阿伟还找来这种 500 克一斤的鹅卵石,对池子表面进行装饰。
|
141 |
+
没错,水源问题阿伟早已经考虑在内,他打算直接在旁边挖个水井,毕竟已经挖了这么深,再向下挖一挖,应该就能到达地下水的深度。经过几日的奋战,能看得出阿伟已经消瘦了不少,但一想到马上就能拥有的豪宅,他直接化身为无情的挖土机器,很快就挖到了好几米的深度。
|
142 |
+
考虑到自己的弹跳力有限,阿伟在一旁定入木桩,然后通过绳子爬上爬下。随着深度越来越深,井底已经开始渗出水来,这也预示着打井成功。没多久这里面将渗满泉水,仅凭一次就能挖到水源,看来这里还真是块风湿宝地。
|
143 |
+
随后阿伟在井口四周挖出凹槽,以便于井盖的安置。这一量才知道,井的深度已经达到了足足的 5 米。阿伟把木板组合在一起,再沿着标记切掉多余部分,他甚至还给井盖做了把手。可是如何从这么深的井里打水还是个问题,但从阿伟坚定的眼神来看,他应该想到了解决办法。
|
144 |
+
只见他将树桩锯成两半,然后用凿子把里面一点点掏空,另外一半也是如法炮制。接着还要在底部挖出圆孔,要想成功将水从 5 米深的地方抽上来,那就不得不提到大家熟知的勾股定理。没错,这跟勾股定理没什么关系。
|
145 |
+
阿伟给竹筒做了一个木塞,并在里面打上安装连接轴的孔。为了增加密闭性,阿伟不得不牺牲了自己的 AJ,剪出与木塞相同的大小后,再用木钉固定住。随后他收集了一些树胶,并放到火上加热融化。接下来就可以涂在木塞上增加使用寿命。
|
146 |
+
现在将竹筒组装完成,就可以利用虹吸原理将水抽上来。完成后就可以把井盖盖上去,再用泥土在上面覆盖,现在就不用担心失足掉下去了。
|
147 |
+
接下来阿伟去采集了一些大漆,将它涂抹在木桶接缝处,就能将其二合为一。完了再接入旁边浴缸的入水口,每个连接的地方都要做好密封,不然后面很容易漏水。随后就可以安装上活塞,并用一根木桩作为省力杠杆,根据空气压强的原理将井水抽上来。
|
148 |
+
经过半小时的来回拉扯,硕大的浴缸终于被灌满,阿伟也是忍不住洗了把脸。接下来还需要解决排水的问题,阿伟在地上挖出沟渠,一直贯穿到屋外,然后再用竹筒从出水口连接,每个接口处都要抹上胶水,就连门外的出水口他都做了隐藏。
|
149 |
+
在野外最重要的就是庇护所、水源还有食物。既然已经完成了前二者,那么阿伟还需要拥有可持续发展的食物来源。他先是在地上挖了两排地洞,然后在每根竹筒的表面都打上无数孔洞,这就是他打算用来种植的载体。在此之前,还需要用大火对竹筒进行杀菌消毒。
|
150 |
+
趁着这时候,他去搬了一麻袋的木屑,先用芭蕉叶覆盖在上面,再铺上厚厚的黏土隔绝温度。在火焰的温度下,能让里面的木屑达到生长条件。
|
151 |
+
等到第二天所有材料都晾凉后,阿伟才将竹筒内部掏空,并将木屑一点点地塞入竹筒。一切准备就绪,就可以将竹筒插入提前挖好的地洞。最后再往竹筒里塞入种子,依靠房间内的湿度和温度,就能达到大棚种植的效果。稍加时日,这些种子就会慢慢发芽。
|
152 |
+
虽然暂时还吃不上自己培养的食物,但好在阿伟从表哥贺强那里学到不少钓鱼本领,哪怕只有一根小小的竹竿,也能让他钓上两斤半的大鲶鱼。新鲜的食材,那肯定是少不了高温消毒的过程。趁着鱼没熟,阿伟直接爬进浴缸,冰凉的井水瞬间洗去了身上的疲惫。这一刻的阿伟是无比的享受。
|
153 |
+
不久后鱼也烤得差不多了,阿伟的生活现在可以说是有滋有味。住在十几米的地下,不仅能安全感满满,哪怕遇到危险,还能通过轨道快速逃生。
|
154 |
+
<example_text_3>
|
155 |
+
|
156 |
+
<video_frame_description>
|
157 |
+
%s
|
158 |
+
</video_frame_description>
|
159 |
+
|
160 |
+
我正在尝试做这个内容的解说纪录片视频,我需要你以 <video_frame_description> </video_frame_description> 中的内容为解说目标,根据我刚才提供给你的对标文案 <example_text> 特点,以及你总结的特点,帮我生成一段关于荒野建造的解说文案,文案需要符合平台受欢迎的解说风格,请使用 json 格式进行输出;使用 <output> 中的输出格式:
|
161 |
+
|
162 |
+
<output>
|
163 |
+
{
|
164 |
+
"items": [
|
165 |
+
{
|
166 |
+
"_id": 1, # 唯一递增id
|
167 |
+
"timestamp": "00:00:05,390-00:00:10,430",
|
168 |
+
"picture": "画面描述",
|
169 |
+
"narration": "解说文案",
|
170 |
+
}
|
171 |
+
}
|
172 |
+
</output>
|
173 |
+
|
174 |
+
<restriction>
|
175 |
+
1. 只输出 json 内容,不要输出其他任何说明性的文字
|
176 |
+
2. 解说文案的语言使用 简体中文
|
177 |
+
3. 严禁虚构画面,所有画面只能从 <video_frame_description> 中摘取
|
178 |
+
</restriction>
|
179 |
+
""" % (markdown_content)
|
180 |
+
|
181 |
+
# 使用OpenAI SDK初始化客户端
|
182 |
+
client = OpenAI(
|
183 |
+
api_key=api_key,
|
184 |
+
base_url=base_url
|
185 |
+
)
|
186 |
+
|
187 |
+
# 使用SDK发送请求
|
188 |
+
if model not in ["deepseek-reasoner"]:
|
189 |
+
# deepseek-reasoner 不支持 json 输出
|
190 |
+
response = client.chat.completions.create(
|
191 |
+
model=model,
|
192 |
+
messages=[
|
193 |
+
{"role": "system", "content": "你是一名专业的短视频解说文案撰写专家。"},
|
194 |
+
{"role": "user", "content": prompt}
|
195 |
+
],
|
196 |
+
temperature=1.5,
|
197 |
+
response_format={"type": "json_object"},
|
198 |
+
)
|
199 |
+
# 提取生成的文案
|
200 |
+
if response.choices and len(response.choices) > 0:
|
201 |
+
narration_script = response.choices[0].message.content
|
202 |
+
# 打印消耗的tokens
|
203 |
+
logger.debug(f"消耗的tokens: {response.usage.total_tokens}")
|
204 |
+
return narration_script
|
205 |
+
else:
|
206 |
+
return "生成解说文案失败: 未获取到有效响应"
|
207 |
+
else:
|
208 |
+
# 不支持 json 输出,需要多一步处理 ```json ``` 的步骤
|
209 |
+
response = client.chat.completions.create(
|
210 |
+
model=model,
|
211 |
+
messages=[
|
212 |
+
{"role": "system", "content": "你是一名专业的短视频解说文案撰写专家。"},
|
213 |
+
{"role": "user", "content": prompt}
|
214 |
+
],
|
215 |
+
temperature=1.5,
|
216 |
+
)
|
217 |
+
# 提取生成的文案
|
218 |
+
if response.choices and len(response.choices) > 0:
|
219 |
+
narration_script = response.choices[0].message.content
|
220 |
+
# 打印消耗的tokens
|
221 |
+
logger.debug(f"文案消耗的tokens: {response.usage.total_tokens}")
|
222 |
+
# 清理 narration_script 字符串前后的 ```json ``` 字符串
|
223 |
+
narration_script = narration_script.replace("```json", "").replace("```", "")
|
224 |
+
return narration_script
|
225 |
+
else:
|
226 |
+
return "生成解说文案失败: 未获取到有效响应"
|
227 |
+
|
228 |
+
except Exception as e:
|
229 |
+
return f"调用API生成解说文案时出错: {traceback.format_exc()}"
|
230 |
+
|
231 |
+
|
232 |
+
if __name__ == '__main__':
|
233 |
+
text_provider = 'openai'
|
234 |
+
text_api_key = "sk-xxx"
|
235 |
+
text_model = "deepseek-reasoner"
|
236 |
+
text_base_url = "https://api.deepseek.com"
|
237 |
+
video_frame_description_path = "/Users/apple/Desktop/home/NarratoAI/storage/temp/analysis/frame_analysis_20250508_1139.json"
|
238 |
+
|
239 |
+
# 测试新的JSON文件
|
240 |
+
test_file_path = "/Users/apple/Desktop/home/NarratoAI/storage/temp/analysis/frame_analysis_20250508_2258.json"
|
241 |
+
markdown_output = parse_frame_analysis_to_markdown(test_file_path)
|
242 |
+
# print(markdown_output)
|
243 |
+
|
244 |
+
# 输出到文件以便检查格式
|
245 |
+
output_file = "/Users/apple/Desktop/home/NarratoAI/storage/temp/家里家外1-5.md"
|
246 |
+
with open(output_file, 'w', encoding='utf-8') as f:
|
247 |
+
f.write(markdown_output)
|
248 |
+
# print(f"\n已将Markdown输出保存到: {output_file}")
|
249 |
+
|
250 |
+
# # 生成解说文案
|
251 |
+
# narration = generate_narration(
|
252 |
+
# markdown_output,
|
253 |
+
# text_api_key,
|
254 |
+
# base_url=text_base_url,
|
255 |
+
# model=text_model
|
256 |
+
# )
|
257 |
+
#
|
258 |
+
# # 保存解说文案
|
259 |
+
# print(narration)
|
260 |
+
# print(type(narration))
|
261 |
+
# narration_file = "/Users/apple/Desktop/home/NarratoAI/storage/temp/final_narration_script.json"
|
262 |
+
# with open(narration_file, 'w', encoding='utf-8') as f:
|
263 |
+
# f.write(narration)
|
264 |
+
# print(f"\n已将解说文案保存到: {narration_file}")
|
app/services/generate_video.py
ADDED
@@ -0,0 +1,393 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
#!/usr/bin/env python
|
2 |
+
# -*- coding: UTF-8 -*-
|
3 |
+
|
4 |
+
'''
|
5 |
+
@Project: NarratoAI
|
6 |
+
@File : generate_video
|
7 |
+
@Author : 小林同学
|
8 |
+
@Date : 2025/5/7 上午11:55
|
9 |
+
'''
|
10 |
+
|
11 |
+
import os
|
12 |
+
import traceback
|
13 |
+
from typing import Optional, Dict, Any
|
14 |
+
from loguru import logger
|
15 |
+
from moviepy import (
|
16 |
+
VideoFileClip,
|
17 |
+
AudioFileClip,
|
18 |
+
CompositeAudioClip,
|
19 |
+
CompositeVideoClip,
|
20 |
+
TextClip,
|
21 |
+
afx
|
22 |
+
)
|
23 |
+
from moviepy.video.tools.subtitles import SubtitlesClip
|
24 |
+
from PIL import ImageFont
|
25 |
+
|
26 |
+
from app.utils import utils
|
27 |
+
|
28 |
+
|
29 |
+
def merge_materials(
|
30 |
+
video_path: str,
|
31 |
+
audio_path: str,
|
32 |
+
output_path: str,
|
33 |
+
subtitle_path: Optional[str] = None,
|
34 |
+
bgm_path: Optional[str] = None,
|
35 |
+
options: Optional[Dict[str, Any]] = None
|
36 |
+
) -> str:
|
37 |
+
"""
|
38 |
+
合并视频、音频、BGM和字幕素材生成最终视频
|
39 |
+
|
40 |
+
参数:
|
41 |
+
video_path: 视频文件路径
|
42 |
+
audio_path: 音频文件路径
|
43 |
+
output_path: 输出文件路径
|
44 |
+
subtitle_path: 字幕文件路径,可选
|
45 |
+
bgm_path: 背景音乐文件路径,可选
|
46 |
+
options: 其他选项配置,可包含以下字段:
|
47 |
+
- voice_volume: 人声音量,默认1.0
|
48 |
+
- bgm_volume: 背景音乐音量,默认0.3
|
49 |
+
- original_audio_volume: 原始音频音量,默认0.0
|
50 |
+
- keep_original_audio: 是否保留原始音频,默认False
|
51 |
+
- subtitle_font: 字幕字体,默认None,系统会使用默认字体
|
52 |
+
- subtitle_font_size: 字幕字体大小,默认40
|
53 |
+
- subtitle_color: 字幕颜色,默认白色
|
54 |
+
- subtitle_bg_color: 字幕背景颜色,默认透明
|
55 |
+
- subtitle_position: 字幕位置,可选值'bottom', 'top', 'center',默认'bottom'
|
56 |
+
- custom_position: 自定义位置
|
57 |
+
- stroke_color: 描边颜色,默认黑色
|
58 |
+
- stroke_width: 描边宽度,默认1
|
59 |
+
- threads: 处理线程数,默认2
|
60 |
+
- fps: 输出帧率,默认30
|
61 |
+
|
62 |
+
返回:
|
63 |
+
输出视频的路径
|
64 |
+
"""
|
65 |
+
# 合并选项默认值
|
66 |
+
if options is None:
|
67 |
+
options = {}
|
68 |
+
|
69 |
+
# 设置默认参数值
|
70 |
+
voice_volume = options.get('voice_volume', 1.0)
|
71 |
+
bgm_volume = options.get('bgm_volume', 0.3)
|
72 |
+
original_audio_volume = options.get('original_audio_volume', 0.0) # 默认为0,即不保留原声
|
73 |
+
keep_original_audio = options.get('keep_original_audio', False) # 是否保留原声
|
74 |
+
subtitle_font = options.get('subtitle_font', '')
|
75 |
+
subtitle_font_size = options.get('subtitle_font_size', 40)
|
76 |
+
subtitle_color = options.get('subtitle_color', '#FFFFFF')
|
77 |
+
subtitle_bg_color = options.get('subtitle_bg_color', 'transparent')
|
78 |
+
subtitle_position = options.get('subtitle_position', 'bottom')
|
79 |
+
custom_position = options.get('custom_position', 70)
|
80 |
+
stroke_color = options.get('stroke_color', '#000000')
|
81 |
+
stroke_width = options.get('stroke_width', 1)
|
82 |
+
threads = options.get('threads', 2)
|
83 |
+
fps = options.get('fps', 30)
|
84 |
+
|
85 |
+
# 处理透明背景色问题 - MoviePy 2.1.1不支持'transparent'值
|
86 |
+
if subtitle_bg_color == 'transparent':
|
87 |
+
subtitle_bg_color = None # None在新版MoviePy中表示透明背景
|
88 |
+
|
89 |
+
# 创建输出目录(如果不存在)
|
90 |
+
output_dir = os.path.dirname(output_path)
|
91 |
+
os.makedirs(output_dir, exist_ok=True)
|
92 |
+
|
93 |
+
logger.info(f"开始合并素材...")
|
94 |
+
logger.info(f" ① 视频: {video_path}")
|
95 |
+
logger.info(f" ② 音频: {audio_path}")
|
96 |
+
if subtitle_path:
|
97 |
+
logger.info(f" ③ 字幕: {subtitle_path}")
|
98 |
+
if bgm_path:
|
99 |
+
logger.info(f" ④ 背景音乐: {bgm_path}")
|
100 |
+
logger.info(f" ⑤ 输出: {output_path}")
|
101 |
+
|
102 |
+
# 加载视频
|
103 |
+
try:
|
104 |
+
video_clip = VideoFileClip(video_path)
|
105 |
+
logger.info(f"视频尺寸: {video_clip.size[0]}x{video_clip.size[1]}, 时长: {video_clip.duration}秒")
|
106 |
+
|
107 |
+
# 提取视频原声(如果需要)
|
108 |
+
original_audio = None
|
109 |
+
if keep_original_audio and original_audio_volume > 0:
|
110 |
+
try:
|
111 |
+
original_audio = video_clip.audio
|
112 |
+
if original_audio:
|
113 |
+
original_audio = original_audio.with_effects([afx.MultiplyVolume(original_audio_volume)])
|
114 |
+
logger.info(f"已提取视频原声,音量设置为: {original_audio_volume}")
|
115 |
+
else:
|
116 |
+
logger.warning("视频没有音轨,无法提取原声")
|
117 |
+
except Exception as e:
|
118 |
+
logger.error(f"提取视频原声失败: {str(e)}")
|
119 |
+
original_audio = None
|
120 |
+
|
121 |
+
# 移除原始音轨,稍后会合并新的音频
|
122 |
+
video_clip = video_clip.without_audio()
|
123 |
+
|
124 |
+
except Exception as e:
|
125 |
+
logger.error(f"加载视频失败: {str(e)}")
|
126 |
+
raise
|
127 |
+
|
128 |
+
# 处理背景音乐和所有音频轨道合成
|
129 |
+
audio_tracks = []
|
130 |
+
|
131 |
+
# 先添加主音频(配音)
|
132 |
+
if audio_path and os.path.exists(audio_path):
|
133 |
+
try:
|
134 |
+
voice_audio = AudioFileClip(audio_path).with_effects([afx.MultiplyVolume(voice_volume)])
|
135 |
+
audio_tracks.append(voice_audio)
|
136 |
+
logger.info(f"已添加配音音频,音量: {voice_volume}")
|
137 |
+
except Exception as e:
|
138 |
+
logger.error(f"加载配音音频失败: {str(e)}")
|
139 |
+
|
140 |
+
# 添加原声(如果需要)
|
141 |
+
if original_audio is not None:
|
142 |
+
audio_tracks.append(original_audio)
|
143 |
+
logger.info(f"已添加视频原声,音量: {original_audio_volume}")
|
144 |
+
|
145 |
+
# 添加背景音乐(如果有)
|
146 |
+
if bgm_path and os.path.exists(bgm_path):
|
147 |
+
try:
|
148 |
+
bgm_clip = AudioFileClip(bgm_path).with_effects([
|
149 |
+
afx.MultiplyVolume(bgm_volume),
|
150 |
+
afx.AudioFadeOut(3),
|
151 |
+
afx.AudioLoop(duration=video_clip.duration),
|
152 |
+
])
|
153 |
+
audio_tracks.append(bgm_clip)
|
154 |
+
logger.info(f"已添加背景音乐,音量: {bgm_volume}")
|
155 |
+
except Exception as e:
|
156 |
+
logger.error(f"添加背景音乐失败: \n{traceback.format_exc()}")
|
157 |
+
|
158 |
+
# 合成最终的音频轨道
|
159 |
+
if audio_tracks:
|
160 |
+
final_audio = CompositeAudioClip(audio_tracks)
|
161 |
+
video_clip = video_clip.with_audio(final_audio)
|
162 |
+
logger.info(f"已合成所有音频轨道,共{len(audio_tracks)}个")
|
163 |
+
else:
|
164 |
+
logger.warning("没有可用的音频轨道,输出视频将没有声音")
|
165 |
+
|
166 |
+
# 处理字体路径
|
167 |
+
font_path = None
|
168 |
+
if subtitle_path and subtitle_font:
|
169 |
+
font_path = os.path.join(utils.font_dir(), subtitle_font)
|
170 |
+
if os.name == "nt":
|
171 |
+
font_path = font_path.replace("\\", "/")
|
172 |
+
logger.info(f"使用字体: {font_path}")
|
173 |
+
|
174 |
+
# 处理视频尺寸
|
175 |
+
video_width, video_height = video_clip.size
|
176 |
+
|
177 |
+
# 字幕处理函数
|
178 |
+
def create_text_clip(subtitle_item):
|
179 |
+
"""创建单个字幕片段"""
|
180 |
+
phrase = subtitle_item[1]
|
181 |
+
max_width = video_width * 0.9
|
182 |
+
|
183 |
+
# 如果有字体路径,进行文本换行处理
|
184 |
+
wrapped_txt = phrase
|
185 |
+
txt_height = 0
|
186 |
+
if font_path:
|
187 |
+
wrapped_txt, txt_height = wrap_text(
|
188 |
+
phrase,
|
189 |
+
max_width=max_width,
|
190 |
+
font=font_path,
|
191 |
+
fontsize=subtitle_font_size
|
192 |
+
)
|
193 |
+
|
194 |
+
# 创建文本片段
|
195 |
+
try:
|
196 |
+
_clip = TextClip(
|
197 |
+
text=wrapped_txt,
|
198 |
+
font=font_path,
|
199 |
+
font_size=subtitle_font_size,
|
200 |
+
color=subtitle_color,
|
201 |
+
bg_color=subtitle_bg_color, # 这里已经在前面处理过,None表示透明
|
202 |
+
stroke_color=stroke_color,
|
203 |
+
stroke_width=stroke_width,
|
204 |
+
)
|
205 |
+
except Exception as e:
|
206 |
+
logger.error(f"创建字幕片段失败: {str(e)}, 使用简化参数重试")
|
207 |
+
# 如果上面的方法失败,尝试使用更简单的参数
|
208 |
+
_clip = TextClip(
|
209 |
+
text=wrapped_txt,
|
210 |
+
font=font_path,
|
211 |
+
font_size=subtitle_font_size,
|
212 |
+
color=subtitle_color,
|
213 |
+
)
|
214 |
+
|
215 |
+
# 设置字幕时间
|
216 |
+
duration = subtitle_item[0][1] - subtitle_item[0][0]
|
217 |
+
_clip = _clip.with_start(subtitle_item[0][0])
|
218 |
+
_clip = _clip.with_end(subtitle_item[0][1])
|
219 |
+
_clip = _clip.with_duration(duration)
|
220 |
+
|
221 |
+
# 设置字幕位置
|
222 |
+
if subtitle_position == "bottom":
|
223 |
+
_clip = _clip.with_position(("center", video_height * 0.95 - _clip.h))
|
224 |
+
elif subtitle_position == "top":
|
225 |
+
_clip = _clip.with_position(("center", video_height * 0.05))
|
226 |
+
elif subtitle_position == "custom":
|
227 |
+
margin = 10
|
228 |
+
max_y = video_height - _clip.h - margin
|
229 |
+
min_y = margin
|
230 |
+
custom_y = (video_height - _clip.h) * (custom_position / 100)
|
231 |
+
custom_y = max(
|
232 |
+
min_y, min(custom_y, max_y)
|
233 |
+
)
|
234 |
+
_clip = _clip.with_position(("center", custom_y))
|
235 |
+
else: # center
|
236 |
+
_clip = _clip.with_position(("center", "center"))
|
237 |
+
|
238 |
+
return _clip
|
239 |
+
|
240 |
+
# 创建TextClip工厂函数
|
241 |
+
def make_textclip(text):
|
242 |
+
return TextClip(
|
243 |
+
text=text,
|
244 |
+
font=font_path,
|
245 |
+
font_size=subtitle_font_size,
|
246 |
+
color=subtitle_color,
|
247 |
+
)
|
248 |
+
|
249 |
+
# 处理字幕
|
250 |
+
if subtitle_path and os.path.exists(subtitle_path):
|
251 |
+
try:
|
252 |
+
# 加载字幕文件
|
253 |
+
sub = SubtitlesClip(
|
254 |
+
subtitles=subtitle_path,
|
255 |
+
encoding="utf-8",
|
256 |
+
make_textclip=make_textclip
|
257 |
+
)
|
258 |
+
|
259 |
+
# 创建每个字幕片段
|
260 |
+
text_clips = []
|
261 |
+
for item in sub.subtitles:
|
262 |
+
clip = create_text_clip(subtitle_item=item)
|
263 |
+
text_clips.append(clip)
|
264 |
+
|
265 |
+
# 合成视频和字幕
|
266 |
+
video_clip = CompositeVideoClip([video_clip, *text_clips])
|
267 |
+
logger.info(f"已添加{len(text_clips)}个字幕片段")
|
268 |
+
except Exception as e:
|
269 |
+
logger.error(f"处理字幕失败: \n{traceback.format_exc()}")
|
270 |
+
|
271 |
+
# 导出最终视频
|
272 |
+
try:
|
273 |
+
video_clip.write_videofile(
|
274 |
+
output_path,
|
275 |
+
audio_codec="aac",
|
276 |
+
temp_audiofile_path=output_dir,
|
277 |
+
threads=threads,
|
278 |
+
fps=fps,
|
279 |
+
)
|
280 |
+
logger.success(f"素材合并完成: {output_path}")
|
281 |
+
except Exception as e:
|
282 |
+
logger.error(f"导出视频失败: {str(e)}")
|
283 |
+
raise
|
284 |
+
finally:
|
285 |
+
# 释放资源
|
286 |
+
video_clip.close()
|
287 |
+
del video_clip
|
288 |
+
|
289 |
+
return output_path
|
290 |
+
|
291 |
+
|
292 |
+
def wrap_text(text, max_width, font="Arial", fontsize=60):
|
293 |
+
"""
|
294 |
+
文本换行函数,使长文本适应指定宽度
|
295 |
+
|
296 |
+
参数:
|
297 |
+
text: 需要换行的文本
|
298 |
+
max_width: 最大宽度(像素)
|
299 |
+
font: 字体路径
|
300 |
+
fontsize: 字体大小
|
301 |
+
|
302 |
+
返回:
|
303 |
+
换行后的文本和文本高度
|
304 |
+
"""
|
305 |
+
# 创建ImageFont对象
|
306 |
+
try:
|
307 |
+
font_obj = ImageFont.truetype(font, fontsize)
|
308 |
+
except:
|
309 |
+
# 如果无法加载指定字体,使用默认字体
|
310 |
+
font_obj = ImageFont.load_default()
|
311 |
+
|
312 |
+
def get_text_size(inner_text):
|
313 |
+
inner_text = inner_text.strip()
|
314 |
+
left, top, right, bottom = font_obj.getbbox(inner_text)
|
315 |
+
return right - left, bottom - top
|
316 |
+
|
317 |
+
width, height = get_text_size(text)
|
318 |
+
if width <= max_width:
|
319 |
+
return text, height
|
320 |
+
|
321 |
+
processed = True
|
322 |
+
|
323 |
+
_wrapped_lines_ = []
|
324 |
+
words = text.split(" ")
|
325 |
+
_txt_ = ""
|
326 |
+
for word in words:
|
327 |
+
_before = _txt_
|
328 |
+
_txt_ += f"{word} "
|
329 |
+
_width, _height = get_text_size(_txt_)
|
330 |
+
if _width <= max_width:
|
331 |
+
continue
|
332 |
+
else:
|
333 |
+
if _txt_.strip() == word.strip():
|
334 |
+
processed = False
|
335 |
+
break
|
336 |
+
_wrapped_lines_.append(_before)
|
337 |
+
_txt_ = f"{word} "
|
338 |
+
_wrapped_lines_.append(_txt_)
|
339 |
+
if processed:
|
340 |
+
_wrapped_lines_ = [line.strip() for line in _wrapped_lines_]
|
341 |
+
result = "\n".join(_wrapped_lines_).strip()
|
342 |
+
height = len(_wrapped_lines_) * height
|
343 |
+
return result, height
|
344 |
+
|
345 |
+
_wrapped_lines_ = []
|
346 |
+
chars = list(text)
|
347 |
+
_txt_ = ""
|
348 |
+
for word in chars:
|
349 |
+
_txt_ += word
|
350 |
+
_width, _height = get_text_size(_txt_)
|
351 |
+
if _width <= max_width:
|
352 |
+
continue
|
353 |
+
else:
|
354 |
+
_wrapped_lines_.append(_txt_)
|
355 |
+
_txt_ = ""
|
356 |
+
_wrapped_lines_.append(_txt_)
|
357 |
+
result = "\n".join(_wrapped_lines_).strip()
|
358 |
+
height = len(_wrapped_lines_) * height
|
359 |
+
return result, height
|
360 |
+
|
361 |
+
|
362 |
+
if __name__ == '__main__':
|
363 |
+
merger_mp4 = '/Users/apple/Desktop/home/NarratoAI/storage/tasks/qyn2-2-demo/merger.mp4'
|
364 |
+
merger_sub = '/Users/apple/Desktop/home/NarratoAI/storage/tasks/qyn2-2-demo/merged_subtitle_00_00_00-00_01_30.srt'
|
365 |
+
merger_audio = '/Users/apple/Desktop/home/NarratoAI/storage/tasks/qyn2-2-demo/merger_audio.mp3'
|
366 |
+
bgm_path = '/Users/apple/Desktop/home/NarratoAI/resource/songs/bgm.mp3'
|
367 |
+
output_video = '/Users/apple/Desktop/home/NarratoAI/storage/tasks/qyn2-2-demo/combined_test.mp4'
|
368 |
+
|
369 |
+
# 调用示例
|
370 |
+
options = {
|
371 |
+
'voice_volume': 1.0, # 配音音量
|
372 |
+
'bgm_volume': 0.1, # 背景音乐音量
|
373 |
+
'original_audio_volume': 1.0, # 视频原声音量,0表示不保留
|
374 |
+
'keep_original_audio': True, # 是否保留原声
|
375 |
+
'subtitle_font': 'MicrosoftYaHeiNormal.ttc', # 这里使用相对字体路径,会自动在 font_dir() 目录下查找
|
376 |
+
'subtitle_font_size': 40,
|
377 |
+
'subtitle_color': '#FFFFFF',
|
378 |
+
'subtitle_bg_color': None, # 直接使用None表示透明背景
|
379 |
+
'subtitle_position': 'bottom',
|
380 |
+
'threads': 2
|
381 |
+
}
|
382 |
+
|
383 |
+
try:
|
384 |
+
merge_materials(
|
385 |
+
video_path=merger_mp4,
|
386 |
+
audio_path=merger_audio,
|
387 |
+
subtitle_path=merger_sub,
|
388 |
+
bgm_path=bgm_path,
|
389 |
+
output_path=output_video,
|
390 |
+
options=options
|
391 |
+
)
|
392 |
+
except Exception as e:
|
393 |
+
logger.error(f"合并素材失败: \n{traceback.format_exc()}")
|
app/services/llm.py
ADDED
@@ -0,0 +1,808 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import os
|
2 |
+
import re
|
3 |
+
import json
|
4 |
+
import traceback
|
5 |
+
import streamlit as st
|
6 |
+
from typing import List
|
7 |
+
from loguru import logger
|
8 |
+
from openai import OpenAI
|
9 |
+
from openai import AzureOpenAI
|
10 |
+
from moviepy import VideoFileClip
|
11 |
+
from openai.types.chat import ChatCompletion
|
12 |
+
import google.generativeai as gemini
|
13 |
+
from googleapiclient.errors import ResumableUploadError
|
14 |
+
from google.api_core.exceptions import *
|
15 |
+
from google.generativeai.types import *
|
16 |
+
import subprocess
|
17 |
+
from typing import Union, TextIO
|
18 |
+
|
19 |
+
from app.config import config
|
20 |
+
from app.utils.utils import clean_model_output
|
21 |
+
|
22 |
+
_max_retries = 5
|
23 |
+
|
24 |
+
Method = """
|
25 |
+
重要提示:每一部剧的文案,前几句必须吸引人
|
26 |
+
首先我们在看完看懂电影后,大脑里面要先有一个大概的轮廓,也就是一个类似于作文的大纲,电影主题线在哪里,首先要找到。
|
27 |
+
一般将文案分为开头、内容、结尾
|
28 |
+
## 开头部分
|
29 |
+
文案开头三句话,是留住用户的关键!
|
30 |
+
|
31 |
+
### 方式一:开头概括总结
|
32 |
+
文案的前三句,是整部电影的概括总结,2-3句介绍后,开始叙述故事剧情!
|
33 |
+
推荐新手(新号)做:(盘点型)
|
34 |
+
盘点全球最恐怖的10部电影
|
35 |
+
盘���全球最科幻的10部电影
|
36 |
+
盘点全球最悲惨的10部电影
|
37 |
+
盘全球最值得看的10部灾难电影
|
38 |
+
盘点全球最值得看的10部励志电影
|
39 |
+
|
40 |
+
下面的示例就是最简单的解说文案开头:
|
41 |
+
1.这是XXX国20年来最大尺度的一部剧,极度烧脑,却让99%的人看得心潮澎湃、无法自拔,故事开始……
|
42 |
+
2.这是有史以来电影院唯一一部全程开灯放完的电影,期间无数人尖叫昏厥,他被成为勇敢者的专属,因为99%的人都不敢看到结局,许多人看完它从此不愿再碰手机,他就是大名鼎鼎的暗黑神作《XXX》……
|
43 |
+
3.这到底是一部什么样的电影,能被55个国家公开抵制,它甚至为了上映,不惜删减掉整整47分钟的剧情……
|
44 |
+
4.是什么样的一个人被豆瓣网友称之为史上最牛P的老太太,都70岁了还要去贩毒……
|
45 |
+
5.他是M国历史上最NB/惨/猖狂/冤枉……的囚犯/抢劫犯/……
|
46 |
+
6.这到底是一部什么样的影片,他一个人就拿了4个顶级奖项,第一季8.7分,第二季直接干到9.5分,11万人给出5星好评,一共也就6集,却斩获26项国际大奖,看过的人都说,他是近年来最好的xxx剧,几乎成为了近年来xxx剧的标杆。故事发生在……
|
47 |
+
7.他是国产电影的巅峰佳作,更是许多80-90后的青春启蒙,曾入选《��代》周刊,获得年度佳片第一,可在国内却被尘封多年,至今为止都无法在各大视频网站看到完整资源,他就是《xxxxxx》
|
48 |
+
8.这是一部让所有人看得荷尔蒙飙升的爽片……
|
49 |
+
9.他被成为世界上最虐心绝望的电影,至今无人敢看第二遍,很难想象,他是根据真实事件改编而来……
|
50 |
+
10.这大概是有史以来最令人不寒而栗的电影,当年一经放映,就点燃了无数人的怒火,不少观众不等影片放完,就愤然离场,它比《xxx》更让人绝望,比比《xxx》更让人xxx,能坚持看完全片的人,更是万中无一,包括我。甚至观影结束后,有无数人抵制投诉这部电影,认为影片的导演玩弄了他们的情感!他是顶级神作《xxxx》……
|
51 |
+
11.这是X国有史以来最高赞的一部悬疑电影,然而却因为某些原因,国内90%的人,没能看过这部片子,他就是《xxx》……
|
52 |
+
12.有这样一部电影,这辈子,你绝对不想再看第二遍,并不是它剧情烂俗,而是它的结局你根本承受不起/想象不到……甚至有80%的观众在观影途中情绪崩溃中途离场,更让许多同行都不想解说这部电影,他就是大名鼎鼎的暗黑神作《xxx》…
|
53 |
+
13.它被誉为史上最牛悬疑片无数人在看完它时候,一个月不敢照镜��,这样一部仅适合部分年龄段观看的影片,究竟有什么样的魅力,竟然获得某瓣8.2的高分,很多人说这部电影到处都是看点,他就是《xxx》….
|
54 |
+
14.这是一部在某瓣上被70万人打出9.3分的高分的电影……到底是一部什么样的电影,能够在某瓣上被70万人打出9.3分的高分……
|
55 |
+
15.这是一部细思极恐的科幻大片,整部电影颠覆你的三观,它的名字叫……
|
56 |
+
16.史上最震撼的灾难片,每一点都不舍得快进的电影,他叫……
|
57 |
+
17.今天给大家带来一部基于真实事件改编的(主题介绍一句……)的故事片,这是一部连环悬疑剧,如果不看到最后绝对想不到结局竟然是这样的反转……
|
58 |
+
|
59 |
+
### 方式:情景式、假设性开头
|
60 |
+
1.他叫……你以为他是……的吗?不。他是来……然后开始叙述
|
61 |
+
2.你知道……吗?原来……然后开始叙述
|
62 |
+
3.如果给你….,你会怎么样?
|
63 |
+
4.如果你是….,你会怎么样?
|
64 |
+
|
65 |
+
### 方式三:以国家为开头!简单明了。话语不需要多,但是需要讲解透彻!
|
66 |
+
1.这是一部韩国最新灾��片,你一定没有看过……
|
67 |
+
2.这是一部印度高分悬疑片,
|
68 |
+
3.这部电影原在日本因为……而被下架,
|
69 |
+
4.这是韩国最恐怖的犯罪片,
|
70 |
+
5.这是最近国产片评分最高的悬疑��
|
71 |
+
以上均按照影片国家来区分,然后简单介绍下主题。就可以开始直接叙述作品。也是一个很不错的方法!
|
72 |
+
|
73 |
+
### 方式四:如何自由发挥
|
74 |
+
正常情况下,每一部电影都有非常关键的一个大纲,这部电影的主题其实是可以用一句话、两句话概括的。只要看懂电影,就能找到这个主题大纲。
|
75 |
+
我们提前把这个主题大纲给放到影视最前面,作为我们的前三句的文案,将会非常吸引人!
|
76 |
+
|
77 |
+
例如:
|
78 |
+
1.这不是电影,这是真实故事。两个女人和一个男人被关在可桑拿室。喊破喉咙也没有一丝回音。窒息感和热度让人抓狂,故事就是从这里开始!
|
79 |
+
2.如果你男朋友出轨了,他不爱你了,还你家暴,怎么办?接下来这部电影就会教你如何让老公服服帖帖的呆在你身边!女主是一个……开始叙述了。
|
80 |
+
3.他力大无穷,双眼放光,这不是拯救地球的超人吗?然而不是。今天给大家推荐的这部电影叫……
|
81 |
+
|
82 |
+
以上是需要看完影片,看懂影片,然后从里面提炼出精彩的几句话,当然是比较难的,当你不会自己去总结前三句的经典的话。可以用前面方式一二三!
|
83 |
+
实在想不出来如何去提炼,可以去搜索这部剧,对这部电影的影评,也会给你带过来很多灵感的!
|
84 |
+
|
85 |
+
|
86 |
+
## 内容部分
|
87 |
+
开头有了,剩下的就是开始叙述正文了。主题介绍是根据影片内容来介绍,如果实在自己想不出来。可以参考其他平台中对这部电影的精彩介绍,提取2-3句也可以!
|
88 |
+
正常情况下,我们叙述的时候其实是非常简单的,把整部电影主题线,叙述下来,其实文案就是加些修饰词把电影重点内容叙述下来。加上一些修饰词。
|
89 |
+
|
90 |
+
以悬疑剧为例:
|
91 |
+
竟然,突然,原来,但是,但,可是,结果,直到,如果,而,果然,发现,只是,出奇,之后,没错,不止,更是,当然,因为,所以……等!
|
92 |
+
以上是比较常用的,当然还有很多,需要靠平时思考和阅读的积累!因悬疑剧会有多处反转剧情。所以需要用到反转的修饰词比较多,只有用到这些词。才能体现出各种反转剧情!
|
93 |
+
建议大家在刚开始做的时候,做8分钟内的,不要太长,分成三段。每段也是不超过三分钟,这样时间刚好。可以比较好的完成完播率!
|
94 |
+
|
95 |
+
|
96 |
+
## 结尾部分
|
97 |
+
最后故事的结局,除了反转,可以来点人生的道理!如果刚开始不会,可以不写。
|
98 |
+
后面水平越来越高的时候,可以进行人生道理的讲评。
|
99 |
+
|
100 |
+
比如:这部电影告诉我们……
|
101 |
+
类似于哲理性质��作为一个总结!
|
102 |
+
也可以把最后的影视反转,原生放出来,留下悬念。
|
103 |
+
|
104 |
+
比如:也可以总结下这部短片如何的好,推荐/值得大家去观看之类的话语。
|
105 |
+
其实就是给我们的作品来一个总结,总结我们所做的三个视频,有开始就要有结束。这个结束不一定是固定的模版。但是视频一定要有结尾。让人感觉有头有尾才最舒服!
|
106 |
+
做解说第一次,可能会做两天。第二次可能就需要一天了。慢慢的。时间缩短到8个小时之内是我们平的制作全部时间!
|
107 |
+
|
108 |
+
"""
|
109 |
+
|
110 |
+
|
111 |
+
def handle_exception(err):
|
112 |
+
if isinstance(err, PermissionDenied):
|
113 |
+
raise Exception("403 用户没有权限访问该资源")
|
114 |
+
elif isinstance(err, ResourceExhausted):
|
115 |
+
raise Exception("429 您的配额已用尽。请稍后重试。请考虑设置自动重试来处理这些错误")
|
116 |
+
elif isinstance(err, InvalidArgument):
|
117 |
+
raise Exception("400 参数无效。例如,文件过大,超出了载荷大小限制。另一个事件提供了无效的 API 密钥。")
|
118 |
+
elif isinstance(err, AlreadyExists):
|
119 |
+
raise Exception("409 已存在具有相同 ID 的已调参模型。对新模型进行调参时,请指定唯一的模型 ID。")
|
120 |
+
elif isinstance(err, RetryError):
|
121 |
+
raise Exception("使用不支持 gRPC 的代理时可能会引起此错误。请尝试将 REST 传输与 genai.configure(..., transport=rest) 搭配使用。")
|
122 |
+
elif isinstance(err, BlockedPromptException):
|
123 |
+
raise Exception("400 出于安全原因,该提示已被屏蔽。")
|
124 |
+
elif isinstance(err, BrokenResponseError):
|
125 |
+
raise Exception("500 流式传输响应已损坏。在访问需要完整响应的内容(例如聊天记录)时引发。查看堆栈轨迹中提供的错误详情。")
|
126 |
+
elif isinstance(err, IncompleteIterationError):
|
127 |
+
raise Exception("500 访问需要完整 API 响应但流式响应尚未完全迭代的内容时引发。对响应对象调用 resolve() 以使用迭代器。")
|
128 |
+
elif isinstance(err, ConnectionError):
|
129 |
+
raise Exception("网络连接错误, 请检查您的网��连接(建议使用 NarratoAI 官方提供的 url)")
|
130 |
+
else:
|
131 |
+
raise Exception(f"大模型请求失败, 下面是具体报错信息: \n\n{traceback.format_exc()}")
|
132 |
+
|
133 |
+
|
134 |
+
def _generate_response(prompt: str, llm_provider: str = None) -> str:
|
135 |
+
"""
|
136 |
+
调用大模型通用方法
|
137 |
+
prompt:
|
138 |
+
llm_provider:
|
139 |
+
"""
|
140 |
+
content = ""
|
141 |
+
if not llm_provider:
|
142 |
+
llm_provider = config.app.get("llm_provider", "openai")
|
143 |
+
logger.info(f"llm provider: {llm_provider}")
|
144 |
+
if llm_provider == "g4f":
|
145 |
+
model_name = config.app.get("g4f_model_name", "")
|
146 |
+
if not model_name:
|
147 |
+
model_name = "gpt-3.5-turbo-16k-0613"
|
148 |
+
import g4f
|
149 |
+
|
150 |
+
content = g4f.ChatCompletion.create(
|
151 |
+
model=model_name,
|
152 |
+
messages=[{"role": "user", "content": prompt}],
|
153 |
+
)
|
154 |
+
else:
|
155 |
+
api_version = "" # for azure
|
156 |
+
if llm_provider == "moonshot":
|
157 |
+
api_key = config.app.get("moonshot_api_key")
|
158 |
+
model_name = config.app.get("moonshot_model_name")
|
159 |
+
base_url = "https://api.moonshot.cn/v1"
|
160 |
+
elif llm_provider == "ollama":
|
161 |
+
# api_key = config.app.get("openai_api_key")
|
162 |
+
api_key = "ollama" # any string works but you are required to have one
|
163 |
+
model_name = config.app.get("ollama_model_name")
|
164 |
+
base_url = config.app.get("ollama_base_url", "")
|
165 |
+
if not base_url:
|
166 |
+
base_url = "http://localhost:11434/v1"
|
167 |
+
elif llm_provider == "openai":
|
168 |
+
api_key = config.app.get("openai_api_key")
|
169 |
+
model_name = config.app.get("openai_model_name")
|
170 |
+
base_url = config.app.get("openai_base_url", "")
|
171 |
+
if not base_url:
|
172 |
+
base_url = "https://api.openai.com/v1"
|
173 |
+
elif llm_provider == "oneapi":
|
174 |
+
api_key = config.app.get("oneapi_api_key")
|
175 |
+
model_name = config.app.get("oneapi_model_name")
|
176 |
+
base_url = config.app.get("oneapi_base_url", "")
|
177 |
+
elif llm_provider == "azure":
|
178 |
+
api_key = config.app.get("azure_api_key")
|
179 |
+
model_name = config.app.get("azure_model_name")
|
180 |
+
base_url = config.app.get("azure_base_url", "")
|
181 |
+
api_version = config.app.get("azure_api_version", "2024-02-15-preview")
|
182 |
+
elif llm_provider == "gemini":
|
183 |
+
api_key = config.app.get("gemini_api_key")
|
184 |
+
model_name = config.app.get("gemini_model_name")
|
185 |
+
base_url = "***"
|
186 |
+
elif llm_provider == "qwen":
|
187 |
+
api_key = config.app.get("qwen_api_key")
|
188 |
+
model_name = config.app.get("qwen_model_name")
|
189 |
+
base_url = "***"
|
190 |
+
elif llm_provider == "cloudflare":
|
191 |
+
api_key = config.app.get("cloudflare_api_key")
|
192 |
+
model_name = config.app.get("cloudflare_model_name")
|
193 |
+
account_id = config.app.get("cloudflare_account_id")
|
194 |
+
base_url = "***"
|
195 |
+
elif llm_provider == "deepseek":
|
196 |
+
api_key = config.app.get("deepseek_api_key")
|
197 |
+
model_name = config.app.get("deepseek_model_name")
|
198 |
+
base_url = config.app.get("deepseek_base_url")
|
199 |
+
if not base_url:
|
200 |
+
base_url = "https://api.deepseek.com"
|
201 |
+
elif llm_provider == "ernie":
|
202 |
+
api_key = config.app.get("ernie_api_key")
|
203 |
+
secret_key = config.app.get("ernie_secret_key")
|
204 |
+
base_url = config.app.get("ernie_base_url")
|
205 |
+
model_name = "***"
|
206 |
+
if not secret_key:
|
207 |
+
raise ValueError(
|
208 |
+
f"{llm_provider}: secret_key is not set, please set it in the config.toml file."
|
209 |
+
)
|
210 |
+
else:
|
211 |
+
raise ValueError(
|
212 |
+
"llm_provider is not set, please set it in the config.toml file."
|
213 |
+
)
|
214 |
+
|
215 |
+
if not api_key:
|
216 |
+
raise ValueError(
|
217 |
+
f"{llm_provider}: api_key is not set, please set it in the config.toml file."
|
218 |
+
)
|
219 |
+
if not model_name:
|
220 |
+
raise ValueError(
|
221 |
+
f"{llm_provider}: model_name is not set, please set it in the config.toml file."
|
222 |
+
)
|
223 |
+
if not base_url:
|
224 |
+
raise ValueError(
|
225 |
+
f"{llm_provider}: base_url is not set, please set it in the config.toml file."
|
226 |
+
)
|
227 |
+
|
228 |
+
if llm_provider == "qwen":
|
229 |
+
import dashscope
|
230 |
+
from dashscope.api_entities.dashscope_response import GenerationResponse
|
231 |
+
|
232 |
+
dashscope.api_key = api_key
|
233 |
+
response = dashscope.Generation.call(
|
234 |
+
model=model_name, messages=[{"role": "user", "content": prompt}]
|
235 |
+
)
|
236 |
+
if response:
|
237 |
+
if isinstance(response, GenerationResponse):
|
238 |
+
status_code = response.status_code
|
239 |
+
if status_code != 200:
|
240 |
+
raise Exception(
|
241 |
+
f'[{llm_provider}] returned an error response: "{response}"'
|
242 |
+
)
|
243 |
+
|
244 |
+
content = response["output"]["text"]
|
245 |
+
return content.replace("\n", "")
|
246 |
+
else:
|
247 |
+
raise Exception(
|
248 |
+
f'[{llm_provider}] returned an invalid response: "{response}"'
|
249 |
+
)
|
250 |
+
else:
|
251 |
+
raise Exception(f"[{llm_provider}] returned an empty response")
|
252 |
+
|
253 |
+
if llm_provider == "gemini":
|
254 |
+
import google.generativeai as genai
|
255 |
+
|
256 |
+
genai.configure(api_key=api_key, transport="rest")
|
257 |
+
|
258 |
+
safety_settings = {
|
259 |
+
HarmCategory.HARM_CATEGORY_HATE_SPEECH: HarmBlockThreshold.BLOCK_NONE,
|
260 |
+
HarmCategory.HARM_CATEGORY_HARASSMENT: HarmBlockThreshold.BLOCK_NONE,
|
261 |
+
HarmCategory.HARM_CATEGORY_SEXUALLY_EXPLICIT: HarmBlockThreshold.BLOCK_NONE,
|
262 |
+
HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT: HarmBlockThreshold.BLOCK_NONE,
|
263 |
+
}
|
264 |
+
|
265 |
+
model = genai.GenerativeModel(
|
266 |
+
model_name=model_name,
|
267 |
+
safety_settings=safety_settings,
|
268 |
+
)
|
269 |
+
|
270 |
+
try:
|
271 |
+
response = model.generate_content(prompt)
|
272 |
+
return response.text
|
273 |
+
except Exception as err:
|
274 |
+
return handle_exception(err)
|
275 |
+
|
276 |
+
if llm_provider == "cloudflare":
|
277 |
+
import requests
|
278 |
+
|
279 |
+
response = requests.post(
|
280 |
+
f"https://api.cloudflare.com/client/v4/accounts/{account_id}/ai/run/{model_name}",
|
281 |
+
headers={"Authorization": f"Bearer {api_key}"},
|
282 |
+
json={
|
283 |
+
"messages": [
|
284 |
+
{"role": "system", "content": "You are a friendly assistant"},
|
285 |
+
{"role": "user", "content": prompt},
|
286 |
+
]
|
287 |
+
},
|
288 |
+
)
|
289 |
+
result = response.json()
|
290 |
+
logger.info(result)
|
291 |
+
return result["result"]["response"]
|
292 |
+
|
293 |
+
if llm_provider == "ernie":
|
294 |
+
import requests
|
295 |
+
|
296 |
+
params = {
|
297 |
+
"grant_type": "client_credentials",
|
298 |
+
"client_id": api_key,
|
299 |
+
"client_secret": secret_key,
|
300 |
+
}
|
301 |
+
access_token = (
|
302 |
+
requests.post("https://aip.baidubce.com/oauth/2.0/token", params=params)
|
303 |
+
.json()
|
304 |
+
.get("access_token")
|
305 |
+
)
|
306 |
+
url = f"{base_url}?access_token={access_token}"
|
307 |
+
|
308 |
+
payload = json.dumps(
|
309 |
+
{
|
310 |
+
"messages": [{"role": "user", "content": prompt}],
|
311 |
+
"temperature": 0.5,
|
312 |
+
"top_p": 0.8,
|
313 |
+
"penalty_score": 1,
|
314 |
+
"disable_search": False,
|
315 |
+
"enable_citation": False,
|
316 |
+
"response_format": "text",
|
317 |
+
}
|
318 |
+
)
|
319 |
+
headers = {"Content-Type": "application/json"}
|
320 |
+
|
321 |
+
response = requests.request(
|
322 |
+
"POST", url, headers=headers, data=payload
|
323 |
+
).json()
|
324 |
+
return response.get("result")
|
325 |
+
|
326 |
+
if llm_provider == "azure":
|
327 |
+
client = AzureOpenAI(
|
328 |
+
api_key=api_key,
|
329 |
+
api_version=api_version,
|
330 |
+
azure_endpoint=base_url,
|
331 |
+
)
|
332 |
+
else:
|
333 |
+
client = OpenAI(
|
334 |
+
api_key=api_key,
|
335 |
+
base_url=base_url,
|
336 |
+
)
|
337 |
+
|
338 |
+
response = client.chat.completions.create(
|
339 |
+
model=model_name, messages=[{"role": "user", "content": prompt}]
|
340 |
+
)
|
341 |
+
if response:
|
342 |
+
if isinstance(response, ChatCompletion):
|
343 |
+
content = response.choices[0].message.content
|
344 |
+
else:
|
345 |
+
raise Exception(
|
346 |
+
f'[{llm_provider}] returned an invalid response: "{response}", please check your network '
|
347 |
+
f"connection and try again."
|
348 |
+
)
|
349 |
+
else:
|
350 |
+
raise Exception(
|
351 |
+
f"[{llm_provider}] returned an empty response, please check your network connection and try again."
|
352 |
+
)
|
353 |
+
|
354 |
+
return content.replace("\n", "")
|
355 |
+
|
356 |
+
|
357 |
+
def _generate_response_video(prompt: str, llm_provider_video: str, video_file: Union[str, TextIO]) -> str:
|
358 |
+
"""
|
359 |
+
多模态能力大模型
|
360 |
+
"""
|
361 |
+
if llm_provider_video == "gemini":
|
362 |
+
api_key = config.app.get("gemini_api_key")
|
363 |
+
model_name = config.app.get("gemini_model_name")
|
364 |
+
base_url = "***"
|
365 |
+
else:
|
366 |
+
raise ValueError(
|
367 |
+
"llm_provider 未设置,请在 config.toml 文件中进行设置。"
|
368 |
+
)
|
369 |
+
|
370 |
+
if llm_provider_video == "gemini":
|
371 |
+
import google.generativeai as genai
|
372 |
+
|
373 |
+
genai.configure(api_key=api_key, transport="rest")
|
374 |
+
|
375 |
+
safety_settings = {
|
376 |
+
HarmCategory.HARM_CATEGORY_HATE_SPEECH: HarmBlockThreshold.BLOCK_NONE,
|
377 |
+
HarmCategory.HARM_CATEGORY_HARASSMENT: HarmBlockThreshold.BLOCK_NONE,
|
378 |
+
HarmCategory.HARM_CATEGORY_SEXUALLY_EXPLICIT: HarmBlockThreshold.BLOCK_NONE,
|
379 |
+
HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT: HarmBlockThreshold.BLOCK_NONE,
|
380 |
+
}
|
381 |
+
|
382 |
+
model = genai.GenerativeModel(
|
383 |
+
model_name=model_name,
|
384 |
+
safety_settings=safety_settings,
|
385 |
+
)
|
386 |
+
|
387 |
+
try:
|
388 |
+
response = model.generate_content([prompt, video_file])
|
389 |
+
return response.text
|
390 |
+
except Exception as err:
|
391 |
+
return handle_exception(err)
|
392 |
+
|
393 |
+
|
394 |
+
def compress_video(input_path: str, output_path: str):
|
395 |
+
"""
|
396 |
+
压缩视频文件
|
397 |
+
Args:
|
398 |
+
input_path: 输入视频文件路径
|
399 |
+
output_path: 输出压缩后的视频文件路径
|
400 |
+
"""
|
401 |
+
# 如果压缩后的视频文件已经存在,则直接使用
|
402 |
+
if os.path.exists(output_path):
|
403 |
+
logger.info(f"压缩视频文件已存在: {output_path}")
|
404 |
+
return
|
405 |
+
|
406 |
+
try:
|
407 |
+
clip = VideoFileClip(input_path)
|
408 |
+
clip.write_videofile(output_path, codec='libx264', audio_codec='aac', bitrate="500k", audio_bitrate="128k")
|
409 |
+
except subprocess.CalledProcessError as e:
|
410 |
+
logger.error(f"视频压缩失败: {e}")
|
411 |
+
raise
|
412 |
+
|
413 |
+
|
414 |
+
def generate_script(
|
415 |
+
video_path: str, video_plot: str, video_name: str, language: str = "zh-CN", progress_callback=None
|
416 |
+
) -> str:
|
417 |
+
"""
|
418 |
+
生成视频剪辑脚本
|
419 |
+
Args:
|
420 |
+
video_path: 视频文件路径
|
421 |
+
video_plot: 视频剧情内容
|
422 |
+
video_name: 视频名称
|
423 |
+
language: 语言
|
424 |
+
progress_callback: 进度回调函数
|
425 |
+
|
426 |
+
Returns:
|
427 |
+
str: 生成的脚本
|
428 |
+
"""
|
429 |
+
try:
|
430 |
+
# 1. 压缩视频
|
431 |
+
compressed_video_path = f"{os.path.splitext(video_path)[0]}_compressed.mp4"
|
432 |
+
compress_video(video_path, compressed_video_path)
|
433 |
+
|
434 |
+
# 在关键步骤更新进度
|
435 |
+
if progress_callback:
|
436 |
+
progress_callback(15, "压缩完成") # 例如,在压缩视频后
|
437 |
+
|
438 |
+
# 2. 转录视频
|
439 |
+
transcription = gemini_video_transcription(
|
440 |
+
video_name=video_name,
|
441 |
+
video_path=compressed_video_path,
|
442 |
+
language=language,
|
443 |
+
llm_provider_video=config.app["video_llm_provider"],
|
444 |
+
progress_callback=progress_callback
|
445 |
+
)
|
446 |
+
if progress_callback:
|
447 |
+
progress_callback(60, "生成解说文案...") # 例如,在转录视频后
|
448 |
+
|
449 |
+
# 3. 编写解说文案
|
450 |
+
script = writing_short_play(video_plot, video_name, config.app["llm_provider"], count=300)
|
451 |
+
|
452 |
+
# 在关键步骤更新进度
|
453 |
+
if progress_callback:
|
454 |
+
progress_callback(70, "匹配画面...") # 例如,在生成脚本后
|
455 |
+
|
456 |
+
# 4. 文案匹配画面
|
457 |
+
if transcription != "":
|
458 |
+
matched_script = screen_matching(huamian=transcription, wenan=script, llm_provider=config.app["video_llm_provider"])
|
459 |
+
# 在关键步骤更新进度
|
460 |
+
if progress_callback:
|
461 |
+
progress_callback(80, "匹配成功")
|
462 |
+
return matched_script
|
463 |
+
else:
|
464 |
+
return ""
|
465 |
+
except Exception as e:
|
466 |
+
handle_exception(e)
|
467 |
+
raise
|
468 |
+
|
469 |
+
|
470 |
+
def gemini_video_transcription(video_name: str, video_path: str, language: str, llm_provider_video: str, progress_callback=None):
|
471 |
+
'''
|
472 |
+
使用 gemini-1.5-xxx 进行视频画面转录
|
473 |
+
'''
|
474 |
+
api_key = config.app.get("gemini_api_key")
|
475 |
+
gemini.configure(api_key=api_key)
|
476 |
+
|
477 |
+
prompt = """
|
478 |
+
请转录音频,包括时间戳,并提供视觉描述,然后以 JSON 格式输出,当前视频中使用的语言为 %s。
|
479 |
+
|
480 |
+
在转录视频时,请通过确保以下条件来完成转录:
|
481 |
+
1. 画面描述使用语言: %s 进行输出。
|
482 |
+
2. 同一个画面合并为一个转录记录。
|
483 |
+
3. 使用以下 JSON schema:
|
484 |
+
Graphics = {"timestamp": "MM:SS-MM:SS"(时间戳格式), "picture": "str"(画面描述), "speech": "str"(台词,如果没有人说话,则使用空字符串。)}
|
485 |
+
Return: list[Graphics]
|
486 |
+
4. 请以严格的 JSON 格式返回数据,不要包含任何注释、标记或其他字符。数据应符合 JSON 语法,可以被 json.loads() 函数直接解析, 不要添加 ```json 或其他标记。
|
487 |
+
""" % (language, language)
|
488 |
+
|
489 |
+
logger.debug(f"视频名称: {video_name}")
|
490 |
+
try:
|
491 |
+
if progress_callback:
|
492 |
+
progress_callback(20, "上传视频至 Google cloud")
|
493 |
+
gemini_video_file = gemini.upload_file(video_path)
|
494 |
+
logger.debug(f"视频 {gemini_video_file.name} 上传至 Google cloud 成功, 开始解析...")
|
495 |
+
while gemini_video_file.state.name == "PROCESSING":
|
496 |
+
gemini_video_file = gemini.get_file(gemini_video_file.name)
|
497 |
+
if progress_callback:
|
498 |
+
progress_callback(30, "上传成功, 开始解析") # 更新进度为20%
|
499 |
+
if gemini_video_file.state.name == "FAILED":
|
500 |
+
raise ValueError(gemini_video_file.state.name)
|
501 |
+
elif gemini_video_file.state.name == "ACTIVE":
|
502 |
+
if progress_callback:
|
503 |
+
progress_callback(40, "解析完成, 开始转录...") # 更新进度为30%
|
504 |
+
logger.debug("解析完成, 开始转录...")
|
505 |
+
except ResumableUploadError as err:
|
506 |
+
logger.error(f"上传视频至 Google cloud 失败, 用户的位置信息不支持用于该API; \n{traceback.format_exc()}")
|
507 |
+
return False
|
508 |
+
except FailedPrecondition as err:
|
509 |
+
logger.error(f"400 用户位置不支持 Google API 使用。\n{traceback.format_exc()}")
|
510 |
+
return False
|
511 |
+
|
512 |
+
if progress_callback:
|
513 |
+
progress_callback(50, "开始转录")
|
514 |
+
try:
|
515 |
+
response = _generate_response_video(prompt=prompt, llm_provider_video=llm_provider_video, video_file=gemini_video_file)
|
516 |
+
logger.success("视频转录成功")
|
517 |
+
logger.debug(response)
|
518 |
+
print(type(response))
|
519 |
+
return response
|
520 |
+
except Exception as err:
|
521 |
+
return handle_exception(err)
|
522 |
+
|
523 |
+
|
524 |
+
def generate_terms(video_subject: str, video_script: str, amount: int = 5) -> List[str]:
|
525 |
+
prompt = f"""
|
526 |
+
# Role: Video Search Terms Generator
|
527 |
+
|
528 |
+
## Goals:
|
529 |
+
Generate {amount} search terms for stock videos, depending on the subject of a video.
|
530 |
+
|
531 |
+
## Constrains:
|
532 |
+
1. the search terms are to be returned as a json-array of strings.
|
533 |
+
2. each search term should consist of 1-3 words, always add the main subject of the video.
|
534 |
+
3. you must only return the json-array of strings. you must not return anything else. you must not return the script.
|
535 |
+
4. the search terms must be related to the subject of the video.
|
536 |
+
5. reply with english search terms only.
|
537 |
+
|
538 |
+
## Output Example:
|
539 |
+
["search term 1", "search term 2", "search term 3","search term 4","search term 5"]
|
540 |
+
|
541 |
+
## Context:
|
542 |
+
### Video Subject
|
543 |
+
{video_subject}
|
544 |
+
|
545 |
+
### Video Script
|
546 |
+
{video_script}
|
547 |
+
|
548 |
+
Please note that you must use English for generating video search terms; Chinese is not accepted.
|
549 |
+
""".strip()
|
550 |
+
|
551 |
+
logger.info(f"subject: {video_subject}")
|
552 |
+
|
553 |
+
search_terms = []
|
554 |
+
response = ""
|
555 |
+
for i in range(_max_retries):
|
556 |
+
try:
|
557 |
+
response = _generate_response(prompt)
|
558 |
+
search_terms = json.loads(response)
|
559 |
+
if not isinstance(search_terms, list) or not all(
|
560 |
+
isinstance(term, str) for term in search_terms
|
561 |
+
):
|
562 |
+
logger.error("response is not a list of strings.")
|
563 |
+
continue
|
564 |
+
|
565 |
+
except Exception as e:
|
566 |
+
logger.warning(f"failed to generate video terms: {str(e)}")
|
567 |
+
if response:
|
568 |
+
match = re.search(r"\[.*]", response)
|
569 |
+
if match:
|
570 |
+
try:
|
571 |
+
search_terms = json.loads(match.group())
|
572 |
+
except Exception as e:
|
573 |
+
logger.warning(f"failed to generate video terms: {str(e)}")
|
574 |
+
pass
|
575 |
+
|
576 |
+
if search_terms and len(search_terms) > 0:
|
577 |
+
break
|
578 |
+
if i < _max_retries:
|
579 |
+
logger.warning(f"failed to generate video terms, trying again... {i + 1}")
|
580 |
+
|
581 |
+
logger.success(f"completed: \n{search_terms}")
|
582 |
+
return search_terms
|
583 |
+
|
584 |
+
|
585 |
+
def gemini_video2json(video_origin_name: str, video_origin_path: str, video_plot: str, language: str) -> str:
|
586 |
+
'''
|
587 |
+
使用 gemini-1.5-pro 进行影视解析
|
588 |
+
Args:
|
589 |
+
video_origin_name: str - 影视作品的原始名称
|
590 |
+
video_origin_path: str - 影视作品的原始路径
|
591 |
+
video_plot: str - 影视作品的简介或剧情概述
|
592 |
+
|
593 |
+
Return:
|
594 |
+
str - 解析后的 JSON 格式字符串
|
595 |
+
'''
|
596 |
+
api_key = config.app.get("gemini_api_key")
|
597 |
+
model_name = config.app.get("gemini_model_name")
|
598 |
+
|
599 |
+
gemini.configure(api_key=api_key)
|
600 |
+
model = gemini.GenerativeModel(model_name=model_name)
|
601 |
+
|
602 |
+
prompt = """
|
603 |
+
**角色设定:**
|
604 |
+
你是一位影视解说专家,擅长根据剧情生成引人入胜的短视频解说文案,特别熟悉适用于TikTok/抖音风格的快速、抓人视频解说。
|
605 |
+
|
606 |
+
**任务目标:**
|
607 |
+
1. 根据给定剧情,详细描述画面,重点突出重要场景和情节。
|
608 |
+
2. 生成符合TikTok/抖音风格的解说,节奏紧凑,语言简洁,吸引观众。
|
609 |
+
3. 解说的时候需要解说一段播放一段原视频,原视频一般为有台词的片段,原视频的控制有 OST 字段控制。
|
610 |
+
4. 结果输出为JSON格式,包含字段:
|
611 |
+
- "picture":画面描述
|
612 |
+
- "timestamp":画面出现的时间范围
|
613 |
+
- "narration":解说内容
|
614 |
+
- "OST": 是否开启原声(true / false)
|
615 |
+
|
616 |
+
**输入示例:**
|
617 |
+
```text
|
618 |
+
在一个���暗的小巷中,主角缓慢走进,四周静谧无声,只有远处隐隐传来猫的叫声。突然,背后出现一个神秘的身影。
|
619 |
+
```
|
620 |
+
|
621 |
+
**输出格式:**
|
622 |
+
```json
|
623 |
+
[
|
624 |
+
{
|
625 |
+
"picture": "黑暗的小巷,主角缓慢走入,四周安静,远处传来猫叫声。",
|
626 |
+
"timestamp": "00:00-00:17",
|
627 |
+
"narration": "静谧的小巷里,主角步步前行,气氛渐渐变得压抑。"
|
628 |
+
"OST": False
|
629 |
+
},
|
630 |
+
{
|
631 |
+
"picture": "神秘身影突然出现,紧张气氛加剧。",
|
632 |
+
"timestamp": "00:17-00:39",
|
633 |
+
"narration": "原声播放"
|
634 |
+
"OST": True
|
635 |
+
}
|
636 |
+
]
|
637 |
+
```
|
638 |
+
|
639 |
+
**提示:**
|
640 |
+
- 文案要简短有力,契合��视频平台用户的观赏习惯。
|
641 |
+
- 保持强烈的悬念和情感代入,吸引观众继续观看。
|
642 |
+
- 解说一段后播放一段原声,原声内容尽量和解说匹配。
|
643 |
+
- 文案语言为:%s
|
644 |
+
- 剧情内容:%s (为空则忽略)
|
645 |
+
|
646 |
+
""" % (language, video_plot)
|
647 |
+
|
648 |
+
logger.debug(f"视频名称: {video_origin_name}")
|
649 |
+
# try:
|
650 |
+
gemini_video_file = gemini.upload_file(video_origin_path)
|
651 |
+
logger.debug(f"上传视频至 Google cloud 成功: {gemini_video_file.name}")
|
652 |
+
while gemini_video_file.state.name == "PROCESSING":
|
653 |
+
import time
|
654 |
+
time.sleep(1)
|
655 |
+
gemini_video_file = gemini.get_file(gemini_video_file.name)
|
656 |
+
logger.debug(f"视频当前状态(ACTIVE才可用): {gemini_video_file.state.name}")
|
657 |
+
if gemini_video_file.state.name == "FAILED":
|
658 |
+
raise ValueError(gemini_video_file.state.name)
|
659 |
+
# except Exception as err:
|
660 |
+
# logger.error(f"上传视频至 Google cloud 失败, 请检查 VPN 配置和 APIKey 是否正确 \n{traceback.format_exc()}")
|
661 |
+
# raise TimeoutError(f"上传视频至 Google cloud 失败, 请检查 VPN 配置和 APIKey 是否正确; {err}")
|
662 |
+
|
663 |
+
streams = model.generate_content([prompt, gemini_video_file], stream=True)
|
664 |
+
response = []
|
665 |
+
for chunk in streams:
|
666 |
+
response.append(chunk.text)
|
667 |
+
|
668 |
+
response = "".join(response)
|
669 |
+
logger.success(f"llm response: \n{response}")
|
670 |
+
|
671 |
+
return response
|
672 |
+
|
673 |
+
|
674 |
+
def writing_movie(video_plot, video_name, llm_provider):
|
675 |
+
"""
|
676 |
+
影视解说(电影解说)
|
677 |
+
"""
|
678 |
+
prompt = f"""
|
679 |
+
**角色设定:**
|
680 |
+
你是一名有10年经验的影视解说文案的创作者,
|
681 |
+
下面是关于如何写解说文案的方法 {Method},请认真阅读它,之后我会给你一部影视作品的名称,然后让你写一篇文案
|
682 |
+
请根据方法撰写 《{video_name}》的影视解说文案,《{video_name}》的大致剧情如下: {video_plot}
|
683 |
+
文案要符合以下要求:
|
684 |
+
|
685 |
+
**任务目标:**
|
686 |
+
1. 文案字数在 1500字左右,严格要求字数,最低不得少于 1000字。
|
687 |
+
2. 避免使用 markdown 格式输出文案。
|
688 |
+
3. 仅输出解说文案,不输出任何其他内容。
|
689 |
+
4. 不要包含小标题,每个段落以 \n 进行分隔。
|
690 |
+
"""
|
691 |
+
try:
|
692 |
+
response = _generate_response(prompt, llm_provider)
|
693 |
+
logger.success("解说文案生成成功")
|
694 |
+
return response
|
695 |
+
except Exception as err:
|
696 |
+
return handle_exception(err)
|
697 |
+
|
698 |
+
|
699 |
+
def writing_short_play(video_plot: str, video_name: str, llm_provider: str, count: int = 500):
|
700 |
+
"""
|
701 |
+
影视解说(短剧解说)
|
702 |
+
"""
|
703 |
+
if not video_plot:
|
704 |
+
raise ValueError("短剧的简介不能为空")
|
705 |
+
if not video_name:
|
706 |
+
raise ValueError("短剧名称不能为空")
|
707 |
+
|
708 |
+
prompt = f"""
|
709 |
+
**角色设定:**
|
710 |
+
你是一名有10年经验的短剧解说文案的创作者,
|
711 |
+
下面是关于如何写解说文案的方法 {Method},请认真阅读它,之后我会给你一部短剧作品的简介,然后让你写一篇解说文案
|
712 |
+
请根据方法撰写 《{video_name}》的解说文案,《{video_name}》的大致剧情如下: {video_plot}
|
713 |
+
文案要符合以下要求:
|
714 |
+
|
715 |
+
**任务目标:**
|
716 |
+
1. 请严格要求文案字数, 字数控制在 {count} 字左右。
|
717 |
+
2. 避免使用 markdown 格式输出文案。
|
718 |
+
3. 仅输出解说文案,不输出任何其他内容。
|
719 |
+
4. 不要包含小标题,每个段落以 \\n 进行分隔。
|
720 |
+
"""
|
721 |
+
try:
|
722 |
+
response = _generate_response(prompt, llm_provider)
|
723 |
+
logger.success("解说文案生成成功")
|
724 |
+
logger.debug(response)
|
725 |
+
return response
|
726 |
+
except Exception as err:
|
727 |
+
return handle_exception(err)
|
728 |
+
|
729 |
+
|
730 |
+
def screen_matching(huamian: str, wenan: str, llm_provider: str):
|
731 |
+
"""
|
732 |
+
画面匹配(一次性匹配)
|
733 |
+
"""
|
734 |
+
if not huamian:
|
735 |
+
raise ValueError("画面不能为空")
|
736 |
+
if not wenan:
|
737 |
+
raise ValueError("文案不能为空")
|
738 |
+
|
739 |
+
prompt = """
|
740 |
+
你是一名有10年经验的影视解说创作者,
|
741 |
+
你的任务是根据视频转录脚本和解说文案,匹配出每段解说文案对应的画面时间戳, 结果以 json 格式输出。
|
742 |
+
|
743 |
+
注意:
|
744 |
+
转录脚本中
|
745 |
+
- timestamp: 表示视频时间戳
|
746 |
+
- picture: 表示当前画面描述
|
747 |
+
- speech": 表示当前视频中人物的台词
|
748 |
+
|
749 |
+
转录脚本和文案(由 XML 标记<PICTURE></PICTURE>和 <COPYWRITER></COPYWRITER>分隔)如下所示:
|
750 |
+
<PICTURE>
|
751 |
+
%s
|
752 |
+
</PICTURE>
|
753 |
+
|
754 |
+
<COPYWRITER>
|
755 |
+
%s
|
756 |
+
</COPYWRITER>
|
757 |
+
|
758 |
+
在匹配的过程中,请通过确保以下条件来完成匹配:
|
759 |
+
- 使用以下 JSON schema:
|
760 |
+
script = {'picture': str, 'timestamp': str(时间戳), "narration": str, "OST": bool(是否开启原声)}
|
761 |
+
Return: list[script]
|
762 |
+
- picture: 字段表示当前画面描述,与转录脚本保持一致
|
763 |
+
- timestamp: 字段表示某一段文案对应的画面的时间戳,不必和转���脚本的时间戳一致,应该充分考虑文案内容,匹配出与其描述最匹配的时间戳
|
764 |
+
- 请注意,请严格的执行已经出现的画面不能重复出现,即生成的脚本中 timestamp 不能有重叠的部分。
|
765 |
+
- narration: 字段表示需要解说文案,每段解说文案尽量不要超过30字
|
766 |
+
- OST: 字段表示是否开启原声,即当 OST 字段为 true 时,narration 字段为空字符串,当 OST 为 false 时,narration 字段为对应的解说文案
|
767 |
+
- 注意,在画面匹配的过程中,需要适当的加入原声播放,使得解说和画面更加匹配,请按照 1:1 的比例,生成原声和解说的脚本内容。
|
768 |
+
- 注意,在时间戳匹配上,一定不能原样照搬“转录脚本”,应当适当的合并或者删减一些片段。
|
769 |
+
- 注意,第一个画面一定是原声播放并且时长不少于 20 s,为了吸引观众,第一段一定是整个转录脚本中最精彩的片段。
|
770 |
+
- 请以严格的 JSON 格式返回数据,不要包含任何注释、标记或其他字符。数据应符合 JSON 语法,可以被 json.loads() 函数直接解析, 不要添加 ```json 或其他标记。
|
771 |
+
""" % (huamian, wenan)
|
772 |
+
|
773 |
+
try:
|
774 |
+
response = _generate_response(prompt, llm_provider)
|
775 |
+
logger.success("匹配成功")
|
776 |
+
logger.debug(response)
|
777 |
+
return response
|
778 |
+
except Exception as err:
|
779 |
+
return handle_exception(err)
|
780 |
+
|
781 |
+
|
782 |
+
if __name__ == "__main__":
|
783 |
+
# 1. 视频转录
|
784 |
+
video_subject = "第二十条之无罪释放"
|
785 |
+
video_path = "/Users/apple/Desktop/home/pipedream_project/downloads/jianzao.mp4"
|
786 |
+
language = "zh-CN"
|
787 |
+
gemini_video_transcription(
|
788 |
+
video_name=video_subject,
|
789 |
+
video_path=video_path,
|
790 |
+
language=language,
|
791 |
+
progress_callback=print,
|
792 |
+
llm_provider_video="gemini"
|
793 |
+
)
|
794 |
+
|
795 |
+
# # 2. 解说文案
|
796 |
+
# video_path = "/Users/apple/Desktop/home/NarratoAI/resource/videos/1.mp4"
|
797 |
+
# # video_path = "E:\\projects\\NarratoAI\\resource\\videos\\1.mp4"
|
798 |
+
# video_plot = """
|
799 |
+
# 李自忠拿着儿子李牧名下的存折,去银行取钱给儿子救命,却被要求证明"你儿子是你儿子"。
|
800 |
+
# 走投无路时碰到银行被抢劫,劫匪给了他两沓钱救命,李自忠却因此被银行以抢劫罪起诉,并顶格判处20年有期徒刑。
|
801 |
+
# 苏醒后的李牧坚决为父亲做无罪辩护,面对银行的顶级律师团队,他一个法学院大一学生,能否力挽狂澜,创作奇迹?挥法律之利剑 ,持正义之天平!
|
802 |
+
# """
|
803 |
+
# res = generate_script(video_path, video_plot, video_name="第二十条之无罪释放")
|
804 |
+
# # res = generate_script(video_path, video_plot, video_name="海岸")
|
805 |
+
# print("脚本生成成功:\n", res)
|
806 |
+
# res = clean_model_output(res)
|
807 |
+
# aaa = json.loads(res)
|
808 |
+
# print(json.dumps(aaa, indent=2, ensure_ascii=False))
|
app/services/material.py
ADDED
@@ -0,0 +1,561 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import os
|
2 |
+
import subprocess
|
3 |
+
import random
|
4 |
+
import traceback
|
5 |
+
from urllib.parse import urlencode
|
6 |
+
from datetime import datetime
|
7 |
+
import json
|
8 |
+
|
9 |
+
import requests
|
10 |
+
from typing import List, Optional
|
11 |
+
from loguru import logger
|
12 |
+
from moviepy.video.io.VideoFileClip import VideoFileClip
|
13 |
+
|
14 |
+
from app.config import config
|
15 |
+
from app.models.schema import VideoAspect, VideoConcatMode, MaterialInfo
|
16 |
+
from app.utils import utils
|
17 |
+
from app.utils import ffmpeg_utils
|
18 |
+
|
19 |
+
requested_count = 0
|
20 |
+
|
21 |
+
|
22 |
+
def get_api_key(cfg_key: str):
|
23 |
+
api_keys = config.app.get(cfg_key)
|
24 |
+
if not api_keys:
|
25 |
+
raise ValueError(
|
26 |
+
f"\n\n##### {cfg_key} is not set #####\n\nPlease set it in the config.toml file: {config.config_file}\n\n"
|
27 |
+
f"{utils.to_json(config.app)}"
|
28 |
+
)
|
29 |
+
|
30 |
+
# if only one key is provided, return it
|
31 |
+
if isinstance(api_keys, str):
|
32 |
+
return api_keys
|
33 |
+
|
34 |
+
global requested_count
|
35 |
+
requested_count += 1
|
36 |
+
return api_keys[requested_count % len(api_keys)]
|
37 |
+
|
38 |
+
|
39 |
+
def search_videos_pexels(
|
40 |
+
search_term: str,
|
41 |
+
minimum_duration: int,
|
42 |
+
video_aspect: VideoAspect = VideoAspect.portrait,
|
43 |
+
) -> List[MaterialInfo]:
|
44 |
+
aspect = VideoAspect(video_aspect)
|
45 |
+
video_orientation = aspect.name
|
46 |
+
video_width, video_height = aspect.to_resolution()
|
47 |
+
api_key = get_api_key("pexels_api_keys")
|
48 |
+
headers = {"Authorization": api_key}
|
49 |
+
# Build URL
|
50 |
+
params = {"query": search_term, "per_page": 20, "orientation": video_orientation}
|
51 |
+
query_url = f"https://api.pexels.com/videos/search?{urlencode(params)}"
|
52 |
+
logger.info(f"searching videos: {query_url}, with proxies: {config.proxy}")
|
53 |
+
|
54 |
+
try:
|
55 |
+
r = requests.get(
|
56 |
+
query_url,
|
57 |
+
headers=headers,
|
58 |
+
proxies=config.proxy,
|
59 |
+
verify=False,
|
60 |
+
timeout=(30, 60),
|
61 |
+
)
|
62 |
+
response = r.json()
|
63 |
+
video_items = []
|
64 |
+
if "videos" not in response:
|
65 |
+
logger.error(f"search videos failed: {response}")
|
66 |
+
return video_items
|
67 |
+
videos = response["videos"]
|
68 |
+
# loop through each video in the result
|
69 |
+
for v in videos:
|
70 |
+
duration = v["duration"]
|
71 |
+
# check if video has desired minimum duration
|
72 |
+
if duration < minimum_duration:
|
73 |
+
continue
|
74 |
+
video_files = v["video_files"]
|
75 |
+
# loop through each url to determine the best quality
|
76 |
+
for video in video_files:
|
77 |
+
w = int(video["width"])
|
78 |
+
h = int(video["height"])
|
79 |
+
if w == video_width and h == video_height:
|
80 |
+
item = MaterialInfo()
|
81 |
+
item.provider = "pexels"
|
82 |
+
item.url = video["link"]
|
83 |
+
item.duration = duration
|
84 |
+
video_items.append(item)
|
85 |
+
break
|
86 |
+
return video_items
|
87 |
+
except Exception as e:
|
88 |
+
logger.error(f"search videos failed: {str(e)}")
|
89 |
+
|
90 |
+
return []
|
91 |
+
|
92 |
+
|
93 |
+
def search_videos_pixabay(
|
94 |
+
search_term: str,
|
95 |
+
minimum_duration: int,
|
96 |
+
video_aspect: VideoAspect = VideoAspect.portrait,
|
97 |
+
) -> List[MaterialInfo]:
|
98 |
+
aspect = VideoAspect(video_aspect)
|
99 |
+
|
100 |
+
video_width, video_height = aspect.to_resolution()
|
101 |
+
|
102 |
+
api_key = get_api_key("pixabay_api_keys")
|
103 |
+
# Build URL
|
104 |
+
params = {
|
105 |
+
"q": search_term,
|
106 |
+
"video_type": "all", # Accepted values: "all", "film", "animation"
|
107 |
+
"per_page": 50,
|
108 |
+
"key": api_key,
|
109 |
+
}
|
110 |
+
query_url = f"https://pixabay.com/api/videos/?{urlencode(params)}"
|
111 |
+
logger.info(f"searching videos: {query_url}, with proxies: {config.proxy}")
|
112 |
+
|
113 |
+
try:
|
114 |
+
r = requests.get(
|
115 |
+
query_url, proxies=config.proxy, verify=False, timeout=(30, 60)
|
116 |
+
)
|
117 |
+
response = r.json()
|
118 |
+
video_items = []
|
119 |
+
if "hits" not in response:
|
120 |
+
logger.error(f"search videos failed: {response}")
|
121 |
+
return video_items
|
122 |
+
videos = response["hits"]
|
123 |
+
# loop through each video in the result
|
124 |
+
for v in videos:
|
125 |
+
duration = v["duration"]
|
126 |
+
# check if video has desired minimum duration
|
127 |
+
if duration < minimum_duration:
|
128 |
+
continue
|
129 |
+
video_files = v["videos"]
|
130 |
+
# loop through each url to determine the best quality
|
131 |
+
for video_type in video_files:
|
132 |
+
video = video_files[video_type]
|
133 |
+
w = int(video["width"])
|
134 |
+
h = int(video["height"])
|
135 |
+
if w >= video_width:
|
136 |
+
item = MaterialInfo()
|
137 |
+
item.provider = "pixabay"
|
138 |
+
item.url = video["url"]
|
139 |
+
item.duration = duration
|
140 |
+
video_items.append(item)
|
141 |
+
break
|
142 |
+
return video_items
|
143 |
+
except Exception as e:
|
144 |
+
logger.error(f"search videos failed: {str(e)}")
|
145 |
+
|
146 |
+
return []
|
147 |
+
|
148 |
+
|
149 |
+
def save_video(video_url: str, save_dir: str = "") -> str:
|
150 |
+
if not save_dir:
|
151 |
+
save_dir = utils.storage_dir("cache_videos")
|
152 |
+
|
153 |
+
if not os.path.exists(save_dir):
|
154 |
+
os.makedirs(save_dir)
|
155 |
+
|
156 |
+
url_without_query = video_url.split("?")[0]
|
157 |
+
url_hash = utils.md5(url_without_query)
|
158 |
+
video_id = f"vid-{url_hash}"
|
159 |
+
video_path = f"{save_dir}/{video_id}.mp4"
|
160 |
+
|
161 |
+
# if video already exists, return the path
|
162 |
+
if os.path.exists(video_path) and os.path.getsize(video_path) > 0:
|
163 |
+
logger.info(f"video already exists: {video_path}")
|
164 |
+
return video_path
|
165 |
+
|
166 |
+
# if video does not exist, download it
|
167 |
+
with open(video_path, "wb") as f:
|
168 |
+
f.write(
|
169 |
+
requests.get(
|
170 |
+
video_url, proxies=config.proxy, verify=False, timeout=(60, 240)
|
171 |
+
).content
|
172 |
+
)
|
173 |
+
|
174 |
+
if os.path.exists(video_path) and os.path.getsize(video_path) > 0:
|
175 |
+
try:
|
176 |
+
clip = VideoFileClip(video_path)
|
177 |
+
duration = clip.duration
|
178 |
+
fps = clip.fps
|
179 |
+
clip.close()
|
180 |
+
if duration > 0 and fps > 0:
|
181 |
+
return video_path
|
182 |
+
except Exception as e:
|
183 |
+
try:
|
184 |
+
os.remove(video_path)
|
185 |
+
except Exception as e:
|
186 |
+
logger.warning(f"无效的视频文件: {video_path} => {str(e)}")
|
187 |
+
return ""
|
188 |
+
|
189 |
+
|
190 |
+
def download_videos(
|
191 |
+
task_id: str,
|
192 |
+
search_terms: List[str],
|
193 |
+
source: str = "pexels",
|
194 |
+
video_aspect: VideoAspect = VideoAspect.portrait,
|
195 |
+
video_contact_mode: VideoConcatMode = VideoConcatMode.random,
|
196 |
+
audio_duration: float = 0.0,
|
197 |
+
max_clip_duration: int = 5,
|
198 |
+
) -> List[str]:
|
199 |
+
valid_video_items = []
|
200 |
+
valid_video_urls = []
|
201 |
+
found_duration = 0.0
|
202 |
+
search_videos = search_videos_pexels
|
203 |
+
if source == "pixabay":
|
204 |
+
search_videos = search_videos_pixabay
|
205 |
+
|
206 |
+
for search_term in search_terms:
|
207 |
+
video_items = search_videos(
|
208 |
+
search_term=search_term,
|
209 |
+
minimum_duration=max_clip_duration,
|
210 |
+
video_aspect=video_aspect,
|
211 |
+
)
|
212 |
+
logger.info(f"found {len(video_items)} videos for '{search_term}'")
|
213 |
+
|
214 |
+
for item in video_items:
|
215 |
+
if item.url not in valid_video_urls:
|
216 |
+
valid_video_items.append(item)
|
217 |
+
valid_video_urls.append(item.url)
|
218 |
+
found_duration += item.duration
|
219 |
+
|
220 |
+
logger.info(
|
221 |
+
f"found total videos: {len(valid_video_items)}, required duration: {audio_duration} seconds, found duration: {found_duration} seconds"
|
222 |
+
)
|
223 |
+
video_paths = []
|
224 |
+
|
225 |
+
material_directory = config.app.get("material_directory", "").strip()
|
226 |
+
if material_directory == "task":
|
227 |
+
material_directory = utils.task_dir(task_id)
|
228 |
+
elif material_directory and not os.path.isdir(material_directory):
|
229 |
+
material_directory = ""
|
230 |
+
|
231 |
+
if video_contact_mode.value == VideoConcatMode.random.value:
|
232 |
+
random.shuffle(valid_video_items)
|
233 |
+
|
234 |
+
total_duration = 0.0
|
235 |
+
for item in valid_video_items:
|
236 |
+
try:
|
237 |
+
logger.info(f"downloading video: {item.url}")
|
238 |
+
saved_video_path = save_video(
|
239 |
+
video_url=item.url, save_dir=material_directory
|
240 |
+
)
|
241 |
+
if saved_video_path:
|
242 |
+
logger.info(f"video saved: {saved_video_path}")
|
243 |
+
video_paths.append(saved_video_path)
|
244 |
+
seconds = min(max_clip_duration, item.duration)
|
245 |
+
total_duration += seconds
|
246 |
+
if total_duration > audio_duration:
|
247 |
+
logger.info(
|
248 |
+
f"total duration of downloaded videos: {total_duration} seconds, skip downloading more"
|
249 |
+
)
|
250 |
+
break
|
251 |
+
except Exception as e:
|
252 |
+
logger.error(f"failed to download video: {utils.to_json(item)} => {str(e)}")
|
253 |
+
logger.success(f"downloaded {len(video_paths)} videos")
|
254 |
+
return video_paths
|
255 |
+
|
256 |
+
|
257 |
+
def time_to_seconds(time_str: str) -> float:
|
258 |
+
"""
|
259 |
+
将时间字符串转换为秒数
|
260 |
+
支持格式: 'HH:MM:SS,mmm' (时:分:秒,毫秒)
|
261 |
+
|
262 |
+
Args:
|
263 |
+
time_str: 时间字符串,如 "00:00:20,100"
|
264 |
+
|
265 |
+
Returns:
|
266 |
+
float: 转换后的秒数(包含毫秒)
|
267 |
+
"""
|
268 |
+
try:
|
269 |
+
# 处理毫秒部分
|
270 |
+
if ',' in time_str:
|
271 |
+
time_part, ms_part = time_str.split(',')
|
272 |
+
ms = int(ms_part) / 1000
|
273 |
+
else:
|
274 |
+
time_part = time_str
|
275 |
+
ms = 0
|
276 |
+
|
277 |
+
# 处理时分秒
|
278 |
+
parts = time_part.split(':')
|
279 |
+
if len(parts) == 3: # HH:MM:SS
|
280 |
+
h, m, s = map(int, parts)
|
281 |
+
seconds = h * 3600 + m * 60 + s
|
282 |
+
else:
|
283 |
+
raise ValueError("时间格式必须为 HH:MM:SS,mmm")
|
284 |
+
|
285 |
+
return seconds + ms
|
286 |
+
|
287 |
+
except ValueError as e:
|
288 |
+
logger.error(f"时间格式错误: {time_str}")
|
289 |
+
raise ValueError(f"时间格式错误: 必须为 HH:MM:SS,mmm 格式") from e
|
290 |
+
|
291 |
+
|
292 |
+
def format_timestamp(seconds: float) -> str:
|
293 |
+
"""
|
294 |
+
将秒数转换为可读的时间格式 (HH:MM:SS,mmm)
|
295 |
+
|
296 |
+
Args:
|
297 |
+
seconds: 秒数(可包含毫秒)
|
298 |
+
|
299 |
+
Returns:
|
300 |
+
str: 格式化的时间字符串,如 "00:00:20,100"
|
301 |
+
"""
|
302 |
+
hours = int(seconds // 3600)
|
303 |
+
minutes = int((seconds % 3600) // 60)
|
304 |
+
seconds_remain = seconds % 60
|
305 |
+
whole_seconds = int(seconds_remain)
|
306 |
+
milliseconds = int((seconds_remain - whole_seconds) * 1000)
|
307 |
+
|
308 |
+
return f"{hours:02d}:{minutes:02d}:{whole_seconds:02d},{milliseconds:03d}"
|
309 |
+
|
310 |
+
|
311 |
+
def _detect_hardware_acceleration() -> Optional[str]:
|
312 |
+
"""
|
313 |
+
检测系统可用的硬件加速器
|
314 |
+
|
315 |
+
Returns:
|
316 |
+
Optional[str]: 硬件加速参数,如果不支持则返回None
|
317 |
+
"""
|
318 |
+
# 使用集中式硬件加速检测
|
319 |
+
hwaccel_type = ffmpeg_utils.get_ffmpeg_hwaccel_type()
|
320 |
+
return hwaccel_type
|
321 |
+
|
322 |
+
|
323 |
+
def save_clip_video(timestamp: str, origin_video: str, save_dir: str = "") -> str:
|
324 |
+
"""
|
325 |
+
保存剪辑后的视频
|
326 |
+
|
327 |
+
Args:
|
328 |
+
timestamp: 需要裁剪的时间戳,格式为 'HH:MM:SS,mmm-HH:MM:SS,mmm'
|
329 |
+
例如: '00:00:00,000-00:00:20,100'
|
330 |
+
origin_video: 原视频路径
|
331 |
+
save_dir: 存储目录
|
332 |
+
|
333 |
+
Returns:
|
334 |
+
dict: 裁剪后的视频路径,格式为 {timestamp: video_path}
|
335 |
+
"""
|
336 |
+
# 使用新的路径结构
|
337 |
+
if not save_dir:
|
338 |
+
base_dir = os.path.join(utils.temp_dir(), "clip_video")
|
339 |
+
video_hash = utils.md5(origin_video)
|
340 |
+
save_dir = os.path.join(base_dir, video_hash)
|
341 |
+
|
342 |
+
if not os.path.exists(save_dir):
|
343 |
+
os.makedirs(save_dir)
|
344 |
+
|
345 |
+
# 解析时间戳
|
346 |
+
start_str, end_str = timestamp.split('-')
|
347 |
+
|
348 |
+
# 格式化输出文件名(使用连字符替代冒号和逗号)
|
349 |
+
safe_start_time = start_str.replace(':', '-').replace(',', '-')
|
350 |
+
safe_end_time = end_str.replace(':', '-').replace(',', '-')
|
351 |
+
output_filename = f"vid_{safe_start_time}@{safe_end_time}.mp4"
|
352 |
+
video_path = os.path.join(save_dir, output_filename)
|
353 |
+
|
354 |
+
# 如果视频已存在,直接返回
|
355 |
+
if os.path.exists(video_path) and os.path.getsize(video_path) > 0:
|
356 |
+
logger.info(f"视频已存在: {video_path}")
|
357 |
+
return video_path
|
358 |
+
|
359 |
+
try:
|
360 |
+
# 检查视频是否存在
|
361 |
+
if not os.path.exists(origin_video):
|
362 |
+
logger.error(f"源视频文件不存在: {origin_video}")
|
363 |
+
return ''
|
364 |
+
|
365 |
+
# 获取视频总时长
|
366 |
+
try:
|
367 |
+
probe_cmd = ["ffprobe", "-v", "error", "-show_entries", "format=duration",
|
368 |
+
"-of", "default=noprint_wrappers=1:nokey=1", origin_video]
|
369 |
+
total_duration = float(subprocess.check_output(probe_cmd).decode('utf-8').strip())
|
370 |
+
except subprocess.CalledProcessError as e:
|
371 |
+
logger.error(f"获取视频时长失败: {str(e)}")
|
372 |
+
return ''
|
373 |
+
|
374 |
+
# 计算时间点
|
375 |
+
start = time_to_seconds(start_str)
|
376 |
+
end = time_to_seconds(end_str)
|
377 |
+
|
378 |
+
# 验证时间段
|
379 |
+
if start >= total_duration:
|
380 |
+
logger.warning(f"起始时间 {format_timestamp(start)} ({start:.3f}秒) 超出视频总时长 {format_timestamp(total_duration)} ({total_duration:.3f}秒)")
|
381 |
+
return ''
|
382 |
+
|
383 |
+
if end > total_duration:
|
384 |
+
logger.warning(f"结束时间 {format_timestamp(end)} ({end:.3f}秒) 超出视频总时长 {format_timestamp(total_duration)} ({total_duration:.3f}秒),将自动调整为视频结尾")
|
385 |
+
end = total_duration
|
386 |
+
|
387 |
+
if end <= start:
|
388 |
+
logger.warning(f"结束时间 {format_timestamp(end)} 必须大于起始时间 {format_timestamp(start)}")
|
389 |
+
return ''
|
390 |
+
|
391 |
+
# 计算剪辑时长
|
392 |
+
duration = end - start
|
393 |
+
# logger.info(f"开始剪辑视频: {format_timestamp(start)} - {format_timestamp(end)},时长 {format_timestamp(duration)}")
|
394 |
+
|
395 |
+
# 获取硬件加速选项
|
396 |
+
hwaccel = _detect_hardware_acceleration()
|
397 |
+
hwaccel_args = []
|
398 |
+
if hwaccel:
|
399 |
+
hwaccel_args = ffmpeg_utils.get_ffmpeg_hwaccel_args()
|
400 |
+
|
401 |
+
# 转换为FFmpeg兼容的时间格式(逗号替换为点)
|
402 |
+
ffmpeg_start_time = start_str.replace(',', '.')
|
403 |
+
ffmpeg_end_time = end_str.replace(',', '.')
|
404 |
+
|
405 |
+
# 构建FFmpeg命令
|
406 |
+
ffmpeg_cmd = [
|
407 |
+
"ffmpeg", "-y", *hwaccel_args,
|
408 |
+
"-i", origin_video,
|
409 |
+
"-ss", ffmpeg_start_time,
|
410 |
+
"-to", ffmpeg_end_time,
|
411 |
+
"-c:v", "h264_videotoolbox" if hwaccel == "videotoolbox" else "libx264",
|
412 |
+
"-c:a", "aac",
|
413 |
+
"-strict", "experimental",
|
414 |
+
video_path
|
415 |
+
]
|
416 |
+
|
417 |
+
# 执行FFmpeg命令
|
418 |
+
# logger.info(f"裁剪视频片段: {timestamp} -> {ffmpeg_start_time}到{ffmpeg_end_time}")
|
419 |
+
# logger.debug(f"执行命令: {' '.join(ffmpeg_cmd)}")
|
420 |
+
|
421 |
+
# 在Windows系统上使用UTF-8编码处理输出,避免GBK编码错误
|
422 |
+
is_windows = os.name == 'nt'
|
423 |
+
if is_windows:
|
424 |
+
process = subprocess.run(
|
425 |
+
ffmpeg_cmd,
|
426 |
+
stdout=subprocess.PIPE,
|
427 |
+
stderr=subprocess.PIPE,
|
428 |
+
encoding='utf-8', # 明确指定编码为UTF-8
|
429 |
+
text=True,
|
430 |
+
check=False # 不抛出异常,我们会检查返回码
|
431 |
+
)
|
432 |
+
else:
|
433 |
+
process = subprocess.run(
|
434 |
+
ffmpeg_cmd,
|
435 |
+
stdout=subprocess.PIPE,
|
436 |
+
stderr=subprocess.PIPE,
|
437 |
+
text=True,
|
438 |
+
check=False # 不抛出异常,我们会检查返回码
|
439 |
+
)
|
440 |
+
|
441 |
+
# 检查是否成功
|
442 |
+
if process.returncode != 0:
|
443 |
+
logger.error(f"视频剪辑失败: {process.stderr}")
|
444 |
+
if os.path.exists(video_path):
|
445 |
+
os.remove(video_path)
|
446 |
+
return ''
|
447 |
+
|
448 |
+
# 验证生成的视频文件
|
449 |
+
if os.path.exists(video_path) and os.path.getsize(video_path) > 0:
|
450 |
+
# 检查视频是否可播放
|
451 |
+
probe_cmd = ["ffprobe", "-v", "error", video_path]
|
452 |
+
# 在Windows系统上使用UTF-8编码
|
453 |
+
if is_windows:
|
454 |
+
validate_result = subprocess.run(probe_cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, encoding='utf-8')
|
455 |
+
else:
|
456 |
+
validate_result = subprocess.run(probe_cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
457 |
+
|
458 |
+
if validate_result.returncode == 0:
|
459 |
+
logger.info(f"视频剪辑成功: {video_path}")
|
460 |
+
return video_path
|
461 |
+
|
462 |
+
logger.error("视频文件验证失败")
|
463 |
+
if os.path.exists(video_path):
|
464 |
+
os.remove(video_path)
|
465 |
+
return ''
|
466 |
+
|
467 |
+
except Exception as e:
|
468 |
+
logger.error(f"视频剪辑过程中发生错误: \n{str(traceback.format_exc())}")
|
469 |
+
if os.path.exists(video_path):
|
470 |
+
os.remove(video_path)
|
471 |
+
return ''
|
472 |
+
|
473 |
+
|
474 |
+
def clip_videos(task_id: str, timestamp_terms: List[str], origin_video: str, progress_callback=None) -> dict:
|
475 |
+
"""
|
476 |
+
剪辑视频
|
477 |
+
Args:
|
478 |
+
task_id: 任务id
|
479 |
+
timestamp_terms: 需要剪辑的时间戳列表,如:['00:00:00,000-00:00:20,100', '00:00:43,039-00:00:46,959']
|
480 |
+
origin_video: 原视频路径
|
481 |
+
progress_callback: 进度回调函数
|
482 |
+
|
483 |
+
Returns:
|
484 |
+
剪辑后的视频路径
|
485 |
+
"""
|
486 |
+
video_paths = {}
|
487 |
+
total_items = len(timestamp_terms)
|
488 |
+
for index, item in enumerate(timestamp_terms):
|
489 |
+
material_directory = config.app.get("material_directory", "").strip()
|
490 |
+
try:
|
491 |
+
saved_video_path = save_clip_video(timestamp=item, origin_video=origin_video, save_dir=material_directory)
|
492 |
+
if saved_video_path:
|
493 |
+
video_paths.update({index+1:saved_video_path})
|
494 |
+
|
495 |
+
# 更新进度
|
496 |
+
if progress_callback:
|
497 |
+
progress_callback(index + 1, total_items)
|
498 |
+
except Exception as e:
|
499 |
+
logger.error(f"视频裁剪失败: {utils.to_json(item)} =>\n{str(traceback.format_exc())}")
|
500 |
+
return {}
|
501 |
+
|
502 |
+
logger.success(f"裁剪 {len(video_paths)} videos")
|
503 |
+
# logger.debug(json.dumps(video_paths, indent=4, ensure_ascii=False))
|
504 |
+
return video_paths
|
505 |
+
|
506 |
+
|
507 |
+
def merge_videos(video_paths, ost_list):
|
508 |
+
"""
|
509 |
+
合并多个视频为一个视频,可选择是否保留每个视频的原声。
|
510 |
+
|
511 |
+
:param video_paths: 视频文件路径列表
|
512 |
+
:param ost_list: 是否保留原声的布尔值列表
|
513 |
+
:return: 合并后的视频文件路径
|
514 |
+
"""
|
515 |
+
if len(video_paths) != len(ost_list):
|
516 |
+
raise ValueError("视频路径列表和保留原声列表长度必须相同")
|
517 |
+
|
518 |
+
if not video_paths:
|
519 |
+
raise ValueError("视频路径列表不能为空")
|
520 |
+
|
521 |
+
# 准备临时文件列表
|
522 |
+
temp_file = "temp_file_list.txt"
|
523 |
+
with open(temp_file, "w") as f:
|
524 |
+
for video_path, keep_ost in zip(video_paths, ost_list):
|
525 |
+
if keep_ost:
|
526 |
+
f.write(f"file '{video_path}'\n")
|
527 |
+
else:
|
528 |
+
# 如果不保留原声,创建一个无声的临时视频
|
529 |
+
silent_video = f"silent_{os.path.basename(video_path)}"
|
530 |
+
subprocess.run(["ffmpeg", "-i", video_path, "-c:v", "copy", "-an", silent_video], check=True)
|
531 |
+
f.write(f"file '{silent_video}'\n")
|
532 |
+
|
533 |
+
# 合并视频
|
534 |
+
output_file = "combined.mp4"
|
535 |
+
ffmpeg_cmd = [
|
536 |
+
"ffmpeg",
|
537 |
+
"-f", "concat",
|
538 |
+
"-safe", "0",
|
539 |
+
"-i", temp_file,
|
540 |
+
"-c:v", "copy",
|
541 |
+
"-c:a", "aac",
|
542 |
+
"-strict", "experimental",
|
543 |
+
output_file
|
544 |
+
]
|
545 |
+
|
546 |
+
try:
|
547 |
+
subprocess.run(ffmpeg_cmd, check=True)
|
548 |
+
print(f"视频合并成功:{output_file}")
|
549 |
+
except subprocess.CalledProcessError as e:
|
550 |
+
print(f"视频合并失败:{e}")
|
551 |
+
return None
|
552 |
+
finally:
|
553 |
+
# 清理临时文件
|
554 |
+
os.remove(temp_file)
|
555 |
+
for video_path, keep_ost in zip(video_paths, ost_list):
|
556 |
+
if not keep_ost:
|
557 |
+
silent_video = f"silent_{os.path.basename(video_path)}"
|
558 |
+
if os.path.exists(silent_video):
|
559 |
+
os.remove(silent_video)
|
560 |
+
|
561 |
+
return output_file
|
app/services/merger_video.py
ADDED
@@ -0,0 +1,662 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
#!/usr/bin/env python
|
2 |
+
# -*- coding: UTF-8 -*-
|
3 |
+
|
4 |
+
'''
|
5 |
+
@Project: NarratoAI
|
6 |
+
@File : merger_video
|
7 |
+
@Author : 小林同学
|
8 |
+
@Date : 2025/5/6 下午7:38
|
9 |
+
'''
|
10 |
+
|
11 |
+
import os
|
12 |
+
import shutil
|
13 |
+
import subprocess
|
14 |
+
from enum import Enum
|
15 |
+
from typing import List, Optional, Tuple
|
16 |
+
from loguru import logger
|
17 |
+
|
18 |
+
from app.utils import ffmpeg_utils
|
19 |
+
|
20 |
+
|
21 |
+
class VideoAspect(Enum):
|
22 |
+
"""视频宽高比枚举"""
|
23 |
+
landscape = "16:9" # 横屏 16:9
|
24 |
+
landscape_2 = "4:3"
|
25 |
+
portrait = "9:16" # 竖屏 9:16
|
26 |
+
portrait_2 = "3:4"
|
27 |
+
square = "1:1" # 方形 1:1
|
28 |
+
|
29 |
+
def to_resolution(self) -> Tuple[int, int]:
|
30 |
+
"""根据宽高比返回标准分辨率"""
|
31 |
+
if self == VideoAspect.portrait:
|
32 |
+
return 1080, 1920 # 竖屏 9:16
|
33 |
+
elif self == VideoAspect.portrait_2:
|
34 |
+
return 720, 1280 # 竖屏 4:3
|
35 |
+
elif self == VideoAspect.landscape:
|
36 |
+
return 1920, 1080 # 横屏 16:9
|
37 |
+
elif self == VideoAspect.landscape_2:
|
38 |
+
return 1280, 720 # 横屏 4:3
|
39 |
+
elif self == VideoAspect.square:
|
40 |
+
return 1080, 1080 # 方形 1:1
|
41 |
+
else:
|
42 |
+
return 1080, 1920 # 默认竖屏
|
43 |
+
|
44 |
+
|
45 |
+
def check_ffmpeg_installation() -> bool:
|
46 |
+
"""
|
47 |
+
检查ffmpeg是否已安装
|
48 |
+
|
49 |
+
Returns:
|
50 |
+
bool: 如果安装则返回True,否则返回False
|
51 |
+
"""
|
52 |
+
try:
|
53 |
+
subprocess.run(['ffmpeg', '-version'], stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=True)
|
54 |
+
return True
|
55 |
+
except (subprocess.SubprocessError, FileNotFoundError):
|
56 |
+
logger.error("ffmpeg未安装或不在系统PATH中,请安装ffmpeg")
|
57 |
+
return False
|
58 |
+
|
59 |
+
|
60 |
+
def get_hardware_acceleration_option() -> Optional[str]:
|
61 |
+
"""
|
62 |
+
根据系统环境选择合适的硬件加速选项
|
63 |
+
|
64 |
+
Returns:
|
65 |
+
Optional[str]: 硬件加速参数,如果不支持则返回None
|
66 |
+
"""
|
67 |
+
# 使用集中式硬件加速检测
|
68 |
+
return ffmpeg_utils.get_ffmpeg_hwaccel_type()
|
69 |
+
|
70 |
+
|
71 |
+
def check_video_has_audio(video_path: str) -> bool:
|
72 |
+
"""
|
73 |
+
检查视频是否包含音频流
|
74 |
+
|
75 |
+
Args:
|
76 |
+
video_path: 视频文件路径
|
77 |
+
|
78 |
+
Returns:
|
79 |
+
bool: 如果视频包含音频流则返回True,否则返回False
|
80 |
+
"""
|
81 |
+
if not os.path.exists(video_path):
|
82 |
+
logger.warning(f"视频文件不存在: {video_path}")
|
83 |
+
return False
|
84 |
+
|
85 |
+
probe_cmd = [
|
86 |
+
'ffprobe', '-v', 'error',
|
87 |
+
'-select_streams', 'a:0',
|
88 |
+
'-show_entries', 'stream=codec_type',
|
89 |
+
'-of', 'csv=p=0',
|
90 |
+
video_path
|
91 |
+
]
|
92 |
+
|
93 |
+
try:
|
94 |
+
result = subprocess.run(probe_cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, check=False)
|
95 |
+
return result.stdout.strip() == 'audio'
|
96 |
+
except Exception as e:
|
97 |
+
logger.warning(f"检测视频音频流时出错: {str(e)}")
|
98 |
+
return False
|
99 |
+
|
100 |
+
|
101 |
+
def create_ffmpeg_concat_file(video_paths: List[str], concat_file_path: str) -> str:
|
102 |
+
"""
|
103 |
+
创建ffmpeg合并所需的concat文件
|
104 |
+
|
105 |
+
Args:
|
106 |
+
video_paths: 需要合并的视频文件路径列表
|
107 |
+
concat_file_path: concat文件的输出路径
|
108 |
+
|
109 |
+
Returns:
|
110 |
+
str: concat文件的路径
|
111 |
+
"""
|
112 |
+
with open(concat_file_path, 'w', encoding='utf-8') as f:
|
113 |
+
for video_path in video_paths:
|
114 |
+
# 获取绝对路径
|
115 |
+
abs_path = os.path.abspath(video_path)
|
116 |
+
# 在Windows上将反斜杠替换为正斜杠
|
117 |
+
if os.name == 'nt': # Windows系统
|
118 |
+
abs_path = abs_path.replace('\\', '/')
|
119 |
+
else: # Unix/Mac系统
|
120 |
+
# 转义特殊字符
|
121 |
+
abs_path = abs_path.replace('\\', '\\\\').replace(':', '\\:')
|
122 |
+
|
123 |
+
# 处理路径中的单引号 (如果有)
|
124 |
+
abs_path = abs_path.replace("'", "\\'")
|
125 |
+
|
126 |
+
f.write(f"file '{abs_path}'\n")
|
127 |
+
return concat_file_path
|
128 |
+
|
129 |
+
|
130 |
+
def process_single_video(
|
131 |
+
input_path: str,
|
132 |
+
output_path: str,
|
133 |
+
target_width: int,
|
134 |
+
target_height: int,
|
135 |
+
keep_audio: bool = True,
|
136 |
+
hwaccel: Optional[str] = None
|
137 |
+
) -> str:
|
138 |
+
"""
|
139 |
+
处理单个视频:调整分辨率、帧率等
|
140 |
+
|
141 |
+
Args:
|
142 |
+
input_path: 输入视频路径
|
143 |
+
output_path: 输出视频路径
|
144 |
+
target_width: 目标宽度
|
145 |
+
target_height: 目标高度
|
146 |
+
keep_audio: 是否保留音频
|
147 |
+
hwaccel: 硬件加速选项
|
148 |
+
|
149 |
+
Returns:
|
150 |
+
str: 处理后的视频路径
|
151 |
+
"""
|
152 |
+
if not os.path.exists(input_path):
|
153 |
+
raise FileNotFoundError(f"找不到视频文件: {input_path}")
|
154 |
+
|
155 |
+
# 构建基本命令
|
156 |
+
command = ['ffmpeg', '-y']
|
157 |
+
|
158 |
+
# 安全检查:如果在Windows上,则慎用硬件加速
|
159 |
+
is_windows = os.name == 'nt'
|
160 |
+
if is_windows and hwaccel:
|
161 |
+
logger.info("在Windows系统上检测到硬件加速请求,将进行额外的兼容性检查")
|
162 |
+
try:
|
163 |
+
# 对视频进行快速探测,检测其基本信息
|
164 |
+
probe_cmd = [
|
165 |
+
'ffprobe', '-v', 'error',
|
166 |
+
'-select_streams', 'v:0',
|
167 |
+
'-show_entries', 'stream=codec_name,width,height',
|
168 |
+
'-of', 'csv=p=0',
|
169 |
+
input_path
|
170 |
+
]
|
171 |
+
result = subprocess.run(probe_cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, check=False)
|
172 |
+
|
173 |
+
# 如果探测成功,使用硬件加速;否则降级到软件编码
|
174 |
+
if result.returncode != 0:
|
175 |
+
logger.warning(f"视频探测失败,为安全起见,禁用硬件加速: {result.stderr}")
|
176 |
+
hwaccel = None
|
177 |
+
except Exception as e:
|
178 |
+
logger.warning(f"视频探测出错,禁用硬件加速: {str(e)}")
|
179 |
+
hwaccel = None
|
180 |
+
|
181 |
+
# 添加硬件加速参数(根据前面的安全检查可能已经被禁用)
|
182 |
+
if hwaccel:
|
183 |
+
try:
|
184 |
+
# 使用集中式硬件加速参数
|
185 |
+
hwaccel_args = ffmpeg_utils.get_ffmpeg_hwaccel_args()
|
186 |
+
command.extend(hwaccel_args)
|
187 |
+
except Exception as e:
|
188 |
+
logger.warning(f"应用硬件加速参数时出错: {str(e)},将使用软件编码")
|
189 |
+
# 重置命令,移除可能添加了一半的硬件加速参数
|
190 |
+
command = ['ffmpeg', '-y']
|
191 |
+
|
192 |
+
# 输入文件
|
193 |
+
command.extend(['-i', input_path])
|
194 |
+
|
195 |
+
# 处理音频
|
196 |
+
if not keep_audio:
|
197 |
+
command.extend(['-an']) # 移除音频
|
198 |
+
else:
|
199 |
+
# 检查输入视频是否有音频流
|
200 |
+
has_audio = check_video_has_audio(input_path)
|
201 |
+
if has_audio:
|
202 |
+
command.extend(['-c:a', 'aac', '-b:a', '128k']) # 音频编码为AAC
|
203 |
+
else:
|
204 |
+
logger.warning(f"视频 {input_path} 没有音频流,将会忽略音频设置")
|
205 |
+
command.extend(['-an']) # 没有音频流时移除音频设置
|
206 |
+
|
207 |
+
# 视频处理参数:缩放并添加填充以保持比例
|
208 |
+
scale_filter = f"scale={target_width}:{target_height}:force_original_aspect_ratio=decrease"
|
209 |
+
pad_filter = f"pad={target_width}:{target_height}:(ow-iw)/2:(oh-ih)/2"
|
210 |
+
command.extend([
|
211 |
+
'-vf', f"{scale_filter},{pad_filter}",
|
212 |
+
'-r', '30', # 设置帧率为30fps
|
213 |
+
])
|
214 |
+
|
215 |
+
# 选择编码器 - 考虑到Windows和特定硬件的兼容性
|
216 |
+
use_software_encoder = True
|
217 |
+
|
218 |
+
if hwaccel:
|
219 |
+
# 获取硬件加速类型和编码器信息
|
220 |
+
hwaccel_type = ffmpeg_utils.get_ffmpeg_hwaccel_type()
|
221 |
+
hwaccel_encoder = ffmpeg_utils.get_ffmpeg_hwaccel_encoder()
|
222 |
+
|
223 |
+
if hwaccel_type == 'cuda' or hwaccel_type == 'nvenc':
|
224 |
+
try:
|
225 |
+
# 检查NVENC编码器是否可用
|
226 |
+
encoders_cmd = subprocess.run(
|
227 |
+
["ffmpeg", "-hide_banner", "-encoders"],
|
228 |
+
stderr=subprocess.PIPE, stdout=subprocess.PIPE, text=True, check=False
|
229 |
+
)
|
230 |
+
|
231 |
+
if "h264_nvenc" in encoders_cmd.stdout.lower():
|
232 |
+
command.extend(['-c:v', 'h264_nvenc', '-preset', 'p4', '-profile:v', 'high'])
|
233 |
+
use_software_encoder = False
|
234 |
+
else:
|
235 |
+
logger.warning("NVENC编码器不可用,将使用软件编码")
|
236 |
+
except Exception as e:
|
237 |
+
logger.warning(f"NVENC编码器检测失败: {str(e)},将使用软件编码")
|
238 |
+
elif hwaccel_type == 'qsv':
|
239 |
+
command.extend(['-c:v', 'h264_qsv', '-preset', 'medium'])
|
240 |
+
use_software_encoder = False
|
241 |
+
elif hwaccel_type == 'videotoolbox': # macOS
|
242 |
+
command.extend(['-c:v', 'h264_videotoolbox', '-profile:v', 'high'])
|
243 |
+
use_software_encoder = False
|
244 |
+
elif hwaccel_type == 'vaapi': # Linux VA-API
|
245 |
+
command.extend(['-c:v', 'h264_vaapi', '-profile', '100'])
|
246 |
+
use_software_encoder = False
|
247 |
+
|
248 |
+
# 如果前面的条件未能应用硬件编码器,使用软件编码
|
249 |
+
if use_software_encoder:
|
250 |
+
logger.info("使用软件编码器(libx264)")
|
251 |
+
command.extend(['-c:v', 'libx264', '-preset', 'medium', '-profile:v', 'high'])
|
252 |
+
|
253 |
+
# 设置视频比特率和其他参数
|
254 |
+
command.extend([
|
255 |
+
'-b:v', '5M',
|
256 |
+
'-maxrate', '8M',
|
257 |
+
'-bufsize', '10M',
|
258 |
+
'-pix_fmt', 'yuv420p', # 兼容性更好的颜色格式
|
259 |
+
])
|
260 |
+
|
261 |
+
# 输出文件
|
262 |
+
command.append(output_path)
|
263 |
+
|
264 |
+
# 执行命令
|
265 |
+
try:
|
266 |
+
# logger.info(f"执行FFmpeg命令: {' '.join(command)}")
|
267 |
+
process = subprocess.run(command, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
268 |
+
logger.info(f"视频处理成功: {output_path}")
|
269 |
+
return output_path
|
270 |
+
except subprocess.CalledProcessError as e:
|
271 |
+
error_msg = e.stderr.decode() if e.stderr else str(e)
|
272 |
+
logger.error(f"处理视频失败: {error_msg}")
|
273 |
+
|
274 |
+
# 如果使用硬件加速失败,尝试使用软件编码
|
275 |
+
if hwaccel:
|
276 |
+
logger.info("尝试使用软件编码作为备选方案")
|
277 |
+
try:
|
278 |
+
# 构建新的命令,使用软件编码
|
279 |
+
fallback_cmd = ['ffmpeg', '-y', '-i', input_path]
|
280 |
+
|
281 |
+
# 保持原有的音频设置
|
282 |
+
if not keep_audio:
|
283 |
+
fallback_cmd.extend(['-an'])
|
284 |
+
else:
|
285 |
+
has_audio = check_video_has_audio(input_path)
|
286 |
+
if has_audio:
|
287 |
+
fallback_cmd.extend(['-c:a', 'aac', '-b:a', '128k'])
|
288 |
+
else:
|
289 |
+
fallback_cmd.extend(['-an'])
|
290 |
+
|
291 |
+
# 保持原有的视频过滤器
|
292 |
+
fallback_cmd.extend([
|
293 |
+
'-vf', f"{scale_filter},{pad_filter}",
|
294 |
+
'-r', '30',
|
295 |
+
'-c:v', 'libx264',
|
296 |
+
'-preset', 'medium',
|
297 |
+
'-profile:v', 'high',
|
298 |
+
'-b:v', '5M',
|
299 |
+
'-maxrate', '8M',
|
300 |
+
'-bufsize', '10M',
|
301 |
+
'-pix_fmt', 'yuv420p',
|
302 |
+
output_path
|
303 |
+
])
|
304 |
+
|
305 |
+
logger.info(f"执行备选FFmpeg命令: {' '.join(fallback_cmd)}")
|
306 |
+
subprocess.run(fallback_cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
307 |
+
logger.info(f"使用软件编码成功处理视频: {output_path}")
|
308 |
+
return output_path
|
309 |
+
except subprocess.CalledProcessError as fallback_error:
|
310 |
+
fallback_error_msg = fallback_error.stderr.decode() if fallback_error.stderr else str(fallback_error)
|
311 |
+
logger.error(f"备选软件编码也失败: {fallback_error_msg}")
|
312 |
+
raise RuntimeError(f"无法处理视频 {input_path}: 硬件加速和软件编码都失败")
|
313 |
+
|
314 |
+
# 如果不是硬件加速导致的问题,或者备选方案也失败了,抛出原始错误
|
315 |
+
raise RuntimeError(f"处理视频失败: {error_msg}")
|
316 |
+
|
317 |
+
|
318 |
+
def combine_clip_videos(
|
319 |
+
output_video_path: str,
|
320 |
+
video_paths: List[str],
|
321 |
+
video_ost_list: List[int],
|
322 |
+
video_aspect: VideoAspect = VideoAspect.portrait,
|
323 |
+
threads: int = 4,
|
324 |
+
force_software_encoding: bool = False, # 新参数,强制使用软件编码
|
325 |
+
) -> str:
|
326 |
+
"""
|
327 |
+
合并子视频
|
328 |
+
Args:
|
329 |
+
output_video_path: 合并后的存储路径
|
330 |
+
video_paths: 子视频路径列表
|
331 |
+
video_ost_list: 原声播放列表 (0: 不保留原声, 1: 只保留原声, 2: 保留原声并保留解说)
|
332 |
+
video_aspect: 屏幕比例
|
333 |
+
threads: 线程数
|
334 |
+
force_software_encoding: 是否强制使用软件编码(忽略硬件加速检测)
|
335 |
+
|
336 |
+
Returns:
|
337 |
+
str: 合并后的视频路径
|
338 |
+
"""
|
339 |
+
# 检查ffmpeg是否安装
|
340 |
+
if not check_ffmpeg_installation():
|
341 |
+
raise RuntimeError("未找到ffmpeg,请先安装")
|
342 |
+
|
343 |
+
# 准备输出目录
|
344 |
+
output_dir = os.path.dirname(output_video_path)
|
345 |
+
os.makedirs(output_dir, exist_ok=True)
|
346 |
+
|
347 |
+
# 获取目标分辨率
|
348 |
+
aspect = VideoAspect(video_aspect)
|
349 |
+
video_width, video_height = aspect.to_resolution()
|
350 |
+
|
351 |
+
# 检测可用的硬件加速选项
|
352 |
+
hwaccel = None if force_software_encoding else get_hardware_acceleration_option()
|
353 |
+
if hwaccel:
|
354 |
+
logger.info(f"将使用 {hwaccel} 硬件加速")
|
355 |
+
elif force_software_encoding:
|
356 |
+
logger.info("已强制使用软件编码,跳过硬件加速检测")
|
357 |
+
else:
|
358 |
+
logger.info("未检测到兼容的硬件加速,将使用软件编码")
|
359 |
+
|
360 |
+
# Windows系统上,默认使用软件编码以提高兼容性
|
361 |
+
if os.name == 'nt' and hwaccel:
|
362 |
+
logger.warning("在Windows系统上检测到硬件加速,但为了提高兼容性,建议使用软件编码")
|
363 |
+
# 不强制禁用hwaccel,而是在process_single_video中进行额外安全检查
|
364 |
+
|
365 |
+
# 重组视频路径和原声设置为一个字典列表结构
|
366 |
+
video_segments = []
|
367 |
+
|
368 |
+
# 检查视频路径和原声设置列表长度是否匹配
|
369 |
+
if len(video_paths) != len(video_ost_list):
|
370 |
+
logger.warning(f"视频路径列表({len(video_paths)})和原声设置列表({len(video_ost_list)})长度不匹配")
|
371 |
+
# 调整长度以匹配较短的列表
|
372 |
+
min_length = min(len(video_paths), len(video_ost_list))
|
373 |
+
video_paths = video_paths[:min_length]
|
374 |
+
video_ost_list = video_ost_list[:min_length]
|
375 |
+
|
376 |
+
# 创建视频处理配置字典列表
|
377 |
+
for i, (video_path, video_ost) in enumerate(zip(video_paths, video_ost_list)):
|
378 |
+
if not os.path.exists(video_path):
|
379 |
+
logger.warning(f"视频不存在,跳过: {video_path}")
|
380 |
+
continue
|
381 |
+
|
382 |
+
# 检查是否有音频流
|
383 |
+
has_audio = check_video_has_audio(video_path)
|
384 |
+
|
385 |
+
# 构建视频片段配置
|
386 |
+
segment = {
|
387 |
+
"index": i,
|
388 |
+
"path": video_path,
|
389 |
+
"ost": video_ost,
|
390 |
+
"has_audio": has_audio,
|
391 |
+
"keep_audio": video_ost > 0 and has_audio # 只有当ost>0且实际有音频时才保留
|
392 |
+
}
|
393 |
+
|
394 |
+
# 记录日志
|
395 |
+
if video_ost > 0 and not has_audio:
|
396 |
+
logger.warning(f"视频 {video_path} 设置为保留原声(ost={video_ost}),但该视频没有音频流")
|
397 |
+
|
398 |
+
video_segments.append(segment)
|
399 |
+
|
400 |
+
# 处理每个视频片段
|
401 |
+
processed_videos = []
|
402 |
+
temp_dir = os.path.join(output_dir, "temp_videos")
|
403 |
+
os.makedirs(temp_dir, exist_ok=True)
|
404 |
+
|
405 |
+
try:
|
406 |
+
# 第一阶段:处理所有视频片段到中间文件
|
407 |
+
for segment in video_segments:
|
408 |
+
# 处理单个视频,去除或保留音频
|
409 |
+
temp_output = os.path.join(temp_dir, f"processed_{segment['index']}.mp4")
|
410 |
+
try:
|
411 |
+
process_single_video(
|
412 |
+
input_path=segment['path'],
|
413 |
+
output_path=temp_output,
|
414 |
+
target_width=video_width,
|
415 |
+
target_height=video_height,
|
416 |
+
keep_audio=segment['keep_audio'],
|
417 |
+
hwaccel=hwaccel
|
418 |
+
)
|
419 |
+
processed_videos.append({
|
420 |
+
"index": segment["index"],
|
421 |
+
"path": temp_output,
|
422 |
+
"keep_audio": segment["keep_audio"]
|
423 |
+
})
|
424 |
+
logger.info(f"视频 {segment['index'] + 1}/{len(video_segments)} 处理完成")
|
425 |
+
except Exception as e:
|
426 |
+
logger.error(f"处理视频 {segment['path']} 时出错: {str(e)}")
|
427 |
+
# 如果使用硬件加速失败,尝试使用软件编码
|
428 |
+
if hwaccel and not force_software_encoding:
|
429 |
+
logger.info(f"尝试使用软件编码处理视频 {segment['path']}")
|
430 |
+
try:
|
431 |
+
process_single_video(
|
432 |
+
input_path=segment['path'],
|
433 |
+
output_path=temp_output,
|
434 |
+
target_width=video_width,
|
435 |
+
target_height=video_height,
|
436 |
+
keep_audio=segment['keep_audio'],
|
437 |
+
hwaccel=None # 使用软件编码
|
438 |
+
)
|
439 |
+
processed_videos.append({
|
440 |
+
"index": segment["index"],
|
441 |
+
"path": temp_output,
|
442 |
+
"keep_audio": segment["keep_audio"]
|
443 |
+
})
|
444 |
+
logger.info(f"使用软件编码成功处理视频 {segment['index'] + 1}/{len(video_segments)}")
|
445 |
+
except Exception as fallback_error:
|
446 |
+
logger.error(f"使用软件编码处理视频 {segment['path']} 也失败: {str(fallback_error)}")
|
447 |
+
continue
|
448 |
+
else:
|
449 |
+
continue
|
450 |
+
|
451 |
+
if not processed_videos:
|
452 |
+
raise ValueError("没有有效的视频片段可以合并")
|
453 |
+
|
454 |
+
# 按原始索引排序处理后的视频
|
455 |
+
processed_videos.sort(key=lambda x: x["index"])
|
456 |
+
|
457 |
+
# 第二阶段:分步骤合并视频 - 避免复杂的filter_complex滤镜
|
458 |
+
try:
|
459 |
+
# 1. 首先,将所有没有音频的视频或音频被禁用的视频合并到一个临时文件中
|
460 |
+
video_paths_only = [video["path"] for video in processed_videos]
|
461 |
+
video_concat_path = os.path.join(temp_dir, "video_concat.mp4")
|
462 |
+
|
463 |
+
# 创建concat文件,用于合并视频流
|
464 |
+
concat_file = os.path.join(temp_dir, "concat_list.txt")
|
465 |
+
create_ffmpeg_concat_file(video_paths_only, concat_file)
|
466 |
+
|
467 |
+
# 合并所有视频流,但不包含音频
|
468 |
+
concat_cmd = [
|
469 |
+
'ffmpeg', '-y',
|
470 |
+
'-f', 'concat',
|
471 |
+
'-safe', '0',
|
472 |
+
'-i', concat_file,
|
473 |
+
'-c:v', 'libx264',
|
474 |
+
'-preset', 'medium',
|
475 |
+
'-profile:v', 'high',
|
476 |
+
'-an', # 不包含音频
|
477 |
+
'-threads', str(threads),
|
478 |
+
video_concat_path
|
479 |
+
]
|
480 |
+
|
481 |
+
subprocess.run(concat_cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
482 |
+
logger.info("视频流合并完成")
|
483 |
+
|
484 |
+
# 2. 提取并合并有音频的片段
|
485 |
+
audio_segments = [video for video in processed_videos if video["keep_audio"]]
|
486 |
+
|
487 |
+
if not audio_segments:
|
488 |
+
# 如果没有音频片段,直接使用无音频的合并视频作为最终结果
|
489 |
+
shutil.copy(video_concat_path, output_video_path)
|
490 |
+
logger.info("无音频视频合并完成")
|
491 |
+
return output_video_path
|
492 |
+
|
493 |
+
# 创建音频中间文件
|
494 |
+
audio_files = []
|
495 |
+
for i, segment in enumerate(audio_segments):
|
496 |
+
# 提取音频
|
497 |
+
audio_file = os.path.join(temp_dir, f"audio_{i}.aac")
|
498 |
+
extract_audio_cmd = [
|
499 |
+
'ffmpeg', '-y',
|
500 |
+
'-i', segment["path"],
|
501 |
+
'-vn', # 不包含视频
|
502 |
+
'-c:a', 'aac',
|
503 |
+
'-b:a', '128k',
|
504 |
+
audio_file
|
505 |
+
]
|
506 |
+
subprocess.run(extract_audio_cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
507 |
+
audio_files.append({
|
508 |
+
"index": segment["index"],
|
509 |
+
"path": audio_file
|
510 |
+
})
|
511 |
+
logger.info(f"提取音频 {i+1}/{len(audio_segments)} 完成")
|
512 |
+
|
513 |
+
# 3. 计算每个音频片段的时间位置
|
514 |
+
audio_timings = []
|
515 |
+
current_time = 0.0
|
516 |
+
|
517 |
+
# 获取每个视频片段的时长
|
518 |
+
for i, video in enumerate(processed_videos):
|
519 |
+
duration_cmd = [
|
520 |
+
'ffprobe', '-v', 'error',
|
521 |
+
'-show_entries', 'format=duration',
|
522 |
+
'-of', 'csv=p=0',
|
523 |
+
video["path"]
|
524 |
+
]
|
525 |
+
result = subprocess.run(duration_cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
526 |
+
duration = float(result.stdout.strip())
|
527 |
+
|
528 |
+
# 如果当前片段需要保留音频,记录时间位置
|
529 |
+
if video["keep_audio"]:
|
530 |
+
for audio in audio_files:
|
531 |
+
if audio["index"] == video["index"]:
|
532 |
+
audio_timings.append({
|
533 |
+
"file": audio["path"],
|
534 |
+
"start": current_time,
|
535 |
+
"index": video["index"]
|
536 |
+
})
|
537 |
+
break
|
538 |
+
|
539 |
+
current_time += duration
|
540 |
+
|
541 |
+
# 4. 创建静音音频轨道作为基础
|
542 |
+
silence_audio = os.path.join(temp_dir, "silence.aac")
|
543 |
+
create_silence_cmd = [
|
544 |
+
'ffmpeg', '-y',
|
545 |
+
'-f', 'lavfi',
|
546 |
+
'-i', f'anullsrc=r=44100:cl=stereo',
|
547 |
+
'-t', str(current_time), # 总时长
|
548 |
+
'-c:a', 'aac',
|
549 |
+
'-b:a', '128k',
|
550 |
+
silence_audio
|
551 |
+
]
|
552 |
+
subprocess.run(create_silence_cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
553 |
+
|
554 |
+
# 5. 创建复杂滤镜命令以混合音频
|
555 |
+
filter_script = os.path.join(temp_dir, "filter_script.txt")
|
556 |
+
with open(filter_script, 'w') as f:
|
557 |
+
f.write(f"[0:a]volume=0.0[silence];\n") # 首先静音背景轨道
|
558 |
+
|
559 |
+
# 添加每个音频文件
|
560 |
+
for i, timing in enumerate(audio_timings):
|
561 |
+
f.write(f"[{i+1}:a]adelay={int(timing['start']*1000)}|{int(timing['start']*1000)}[a{i}];\n")
|
562 |
+
|
563 |
+
# 混合所有音频
|
564 |
+
mix_str = "[silence]"
|
565 |
+
for i in range(len(audio_timings)):
|
566 |
+
mix_str += f"[a{i}]"
|
567 |
+
mix_str += f"amix=inputs={len(audio_timings)+1}:duration=longest[aout]"
|
568 |
+
f.write(mix_str)
|
569 |
+
|
570 |
+
# 6. 构建音频合并命令
|
571 |
+
audio_inputs = ['-i', silence_audio]
|
572 |
+
for timing in audio_timings:
|
573 |
+
audio_inputs.extend(['-i', timing["file"]])
|
574 |
+
|
575 |
+
mixed_audio = os.path.join(temp_dir, "mixed_audio.aac")
|
576 |
+
audio_mix_cmd = [
|
577 |
+
'ffmpeg', '-y'
|
578 |
+
] + audio_inputs + [
|
579 |
+
'-filter_complex_script', filter_script,
|
580 |
+
'-map', '[aout]',
|
581 |
+
'-c:a', 'aac',
|
582 |
+
'-b:a', '128k',
|
583 |
+
mixed_audio
|
584 |
+
]
|
585 |
+
|
586 |
+
subprocess.run(audio_mix_cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
587 |
+
logger.info("音频混合完成")
|
588 |
+
|
589 |
+
# 7. 将合并的视频和混合的音频组合在一起
|
590 |
+
final_cmd = [
|
591 |
+
'ffmpeg', '-y',
|
592 |
+
'-i', video_concat_path,
|
593 |
+
'-i', mixed_audio,
|
594 |
+
'-c:v', 'copy',
|
595 |
+
'-c:a', 'aac',
|
596 |
+
'-map', '0:v:0',
|
597 |
+
'-map', '1:a:0',
|
598 |
+
'-shortest',
|
599 |
+
output_video_path
|
600 |
+
]
|
601 |
+
|
602 |
+
subprocess.run(final_cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
603 |
+
logger.info("视频最终合并完成")
|
604 |
+
|
605 |
+
return output_video_path
|
606 |
+
|
607 |
+
except subprocess.CalledProcessError as e:
|
608 |
+
logger.error(f"合并视频过程中出错: {e.stderr.decode() if e.stderr else str(e)}")
|
609 |
+
|
610 |
+
# 尝试备用合并方法 - 最简单的无音频合并
|
611 |
+
logger.info("尝试备用合并方法 - 无音频合并")
|
612 |
+
try:
|
613 |
+
concat_file = os.path.join(temp_dir, "concat_list.txt")
|
614 |
+
video_paths_only = [video["path"] for video in processed_videos]
|
615 |
+
create_ffmpeg_concat_file(video_paths_only, concat_file)
|
616 |
+
|
617 |
+
backup_cmd = [
|
618 |
+
'ffmpeg', '-y',
|
619 |
+
'-f', 'concat',
|
620 |
+
'-safe', '0',
|
621 |
+
'-i', concat_file,
|
622 |
+
'-c:v', 'copy',
|
623 |
+
'-an', # 无音频
|
624 |
+
output_video_path
|
625 |
+
]
|
626 |
+
|
627 |
+
subprocess.run(backup_cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
628 |
+
logger.warning("使用备用方法(无音频)成功合并视频")
|
629 |
+
return output_video_path
|
630 |
+
except Exception as backup_error:
|
631 |
+
logger.error(f"备用合并方法也失败: {str(backup_error)}")
|
632 |
+
raise RuntimeError(f"无法合并视频: {str(backup_error)}")
|
633 |
+
|
634 |
+
except Exception as e:
|
635 |
+
logger.error(f"合并视频时出错: {str(e)}")
|
636 |
+
raise
|
637 |
+
finally:
|
638 |
+
# 清理临时文件
|
639 |
+
try:
|
640 |
+
if os.path.exists(temp_dir):
|
641 |
+
shutil.rmtree(temp_dir)
|
642 |
+
logger.info("已清理临时文件")
|
643 |
+
except Exception as e:
|
644 |
+
logger.warning(f"清理临时文件时出错: {str(e)}")
|
645 |
+
|
646 |
+
|
647 |
+
if __name__ == '__main__':
|
648 |
+
video_paths = [
|
649 |
+
'/Users/apple/Desktop/home/NarratoAI/storage/temp/clip_video/S01E02_00_14_09_440.mp4',
|
650 |
+
'/Users/apple/Desktop/home/NarratoAI/storage/temp/clip_video/S01E08_00_27_11_110.mp4',
|
651 |
+
'/Users/apple/Desktop/home/NarratoAI/storage/temp/clip_video/S01E08_00_34_44_480.mp4',
|
652 |
+
'/Users/apple/Desktop/home/NarratoAI/storage/temp/clip_video/S01E08_00_42_47_630.mp4',
|
653 |
+
'/Users/apple/Desktop/home/NarratoAI/storage/temp/clip_video/S01E09_00_29_48_160.mp4'
|
654 |
+
]
|
655 |
+
|
656 |
+
combine_clip_videos(
|
657 |
+
output_video_path="/Users/apple/Desktop/home/NarratoAI/storage/temp/merge/merged_123.mp4",
|
658 |
+
video_paths=video_paths,
|
659 |
+
video_ost_list=[1, 1, 1,1,1],
|
660 |
+
video_aspect=VideoAspect.portrait,
|
661 |
+
force_software_encoding=False # 默认不强制使用软件编码,让系统自动决定
|
662 |
+
)
|
app/services/script_service.py
ADDED
@@ -0,0 +1,400 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import os
|
2 |
+
import json
|
3 |
+
import time
|
4 |
+
import asyncio
|
5 |
+
import requests
|
6 |
+
from app.utils import video_processor
|
7 |
+
from loguru import logger
|
8 |
+
from typing import List, Dict, Any, Callable
|
9 |
+
|
10 |
+
from app.utils import utils, gemini_analyzer, video_processor
|
11 |
+
from app.utils.script_generator import ScriptProcessor
|
12 |
+
from app.config import config
|
13 |
+
|
14 |
+
|
15 |
+
class ScriptGenerator:
|
16 |
+
def __init__(self):
|
17 |
+
self.temp_dir = utils.temp_dir()
|
18 |
+
self.keyframes_dir = os.path.join(self.temp_dir, "keyframes")
|
19 |
+
|
20 |
+
async def generate_script(
|
21 |
+
self,
|
22 |
+
video_path: str,
|
23 |
+
video_theme: str = "",
|
24 |
+
custom_prompt: str = "",
|
25 |
+
frame_interval_input: int = 5,
|
26 |
+
skip_seconds: int = 0,
|
27 |
+
threshold: int = 30,
|
28 |
+
vision_batch_size: int = 5,
|
29 |
+
vision_llm_provider: str = "gemini",
|
30 |
+
progress_callback: Callable[[float, str], None] = None
|
31 |
+
) -> List[Dict[Any, Any]]:
|
32 |
+
"""
|
33 |
+
生成视频脚本的核心逻辑
|
34 |
+
|
35 |
+
Args:
|
36 |
+
video_path: 视频文件路径
|
37 |
+
video_theme: 视频主题
|
38 |
+
custom_prompt: 自定义提示词
|
39 |
+
skip_seconds: 跳过开始的秒数
|
40 |
+
threshold: 差异���值
|
41 |
+
vision_batch_size: 视觉处理批次大小
|
42 |
+
vision_llm_provider: 视觉模型提供商
|
43 |
+
progress_callback: 进度回调函数
|
44 |
+
|
45 |
+
Returns:
|
46 |
+
List[Dict]: 生成的视频脚本
|
47 |
+
"""
|
48 |
+
if progress_callback is None:
|
49 |
+
progress_callback = lambda p, m: None
|
50 |
+
|
51 |
+
try:
|
52 |
+
# 提取关键帧
|
53 |
+
progress_callback(10, "正在提取关键帧...")
|
54 |
+
keyframe_files = await self._extract_keyframes(
|
55 |
+
video_path,
|
56 |
+
skip_seconds,
|
57 |
+
threshold
|
58 |
+
)
|
59 |
+
|
60 |
+
if vision_llm_provider == "gemini":
|
61 |
+
script = await self._process_with_gemini(
|
62 |
+
keyframe_files,
|
63 |
+
video_theme,
|
64 |
+
custom_prompt,
|
65 |
+
vision_batch_size,
|
66 |
+
progress_callback
|
67 |
+
)
|
68 |
+
elif vision_llm_provider == "narratoapi":
|
69 |
+
script = await self._process_with_narrato(
|
70 |
+
keyframe_files,
|
71 |
+
video_theme,
|
72 |
+
custom_prompt,
|
73 |
+
vision_batch_size,
|
74 |
+
progress_callback
|
75 |
+
)
|
76 |
+
else:
|
77 |
+
raise ValueError(f"Unsupported vision provider: {vision_llm_provider}")
|
78 |
+
|
79 |
+
return json.loads(script) if isinstance(script, str) else script
|
80 |
+
|
81 |
+
except Exception as e:
|
82 |
+
logger.exception("Generate script failed")
|
83 |
+
raise
|
84 |
+
|
85 |
+
async def _extract_keyframes(
|
86 |
+
self,
|
87 |
+
video_path: str,
|
88 |
+
skip_seconds: int,
|
89 |
+
threshold: int
|
90 |
+
) -> List[str]:
|
91 |
+
"""提取视频关键帧"""
|
92 |
+
video_hash = utils.md5(video_path + str(os.path.getmtime(video_path)))
|
93 |
+
video_keyframes_dir = os.path.join(self.keyframes_dir, video_hash)
|
94 |
+
|
95 |
+
# 检查缓存
|
96 |
+
keyframe_files = []
|
97 |
+
if os.path.exists(video_keyframes_dir):
|
98 |
+
for filename in sorted(os.listdir(video_keyframes_dir)):
|
99 |
+
if filename.endswith('.jpg'):
|
100 |
+
keyframe_files.append(os.path.join(video_keyframes_dir, filename))
|
101 |
+
|
102 |
+
if keyframe_files:
|
103 |
+
logger.info(f"Using cached keyframes: {video_keyframes_dir}")
|
104 |
+
return keyframe_files
|
105 |
+
|
106 |
+
# 提取新的关键帧
|
107 |
+
os.makedirs(video_keyframes_dir, exist_ok=True)
|
108 |
+
|
109 |
+
try:
|
110 |
+
processor = video_processor.VideoProcessor(video_path)
|
111 |
+
processor.process_video_pipeline(
|
112 |
+
output_dir=video_keyframes_dir,
|
113 |
+
skip_seconds=skip_seconds,
|
114 |
+
threshold=threshold
|
115 |
+
)
|
116 |
+
|
117 |
+
for filename in sorted(os.listdir(video_keyframes_dir)):
|
118 |
+
if filename.endswith('.jpg'):
|
119 |
+
keyframe_files.append(os.path.join(video_keyframes_dir, filename))
|
120 |
+
|
121 |
+
return keyframe_files
|
122 |
+
|
123 |
+
except Exception as e:
|
124 |
+
if os.path.exists(video_keyframes_dir):
|
125 |
+
import shutil
|
126 |
+
shutil.rmtree(video_keyframes_dir)
|
127 |
+
raise
|
128 |
+
|
129 |
+
async def _process_with_gemini(
|
130 |
+
self,
|
131 |
+
keyframe_files: List[str],
|
132 |
+
video_theme: str,
|
133 |
+
custom_prompt: str,
|
134 |
+
vision_batch_size: int,
|
135 |
+
progress_callback: Callable[[float, str], None]
|
136 |
+
) -> str:
|
137 |
+
"""使用Gemini处理视频帧"""
|
138 |
+
progress_callback(30, "正在初始化视觉分析器...")
|
139 |
+
|
140 |
+
# 获取Gemini配置
|
141 |
+
vision_api_key = config.app.get("vision_gemini_api_key")
|
142 |
+
vision_model = config.app.get("vision_gemini_model_name")
|
143 |
+
|
144 |
+
if not vision_api_key or not vision_model:
|
145 |
+
raise ValueError("未配置 Gemini API Key 或者模型")
|
146 |
+
|
147 |
+
analyzer = gemini_analyzer.VisionAnalyzer(
|
148 |
+
model_name=vision_model,
|
149 |
+
api_key=vision_api_key,
|
150 |
+
)
|
151 |
+
|
152 |
+
progress_callback(40, "正在分析关键帧...")
|
153 |
+
|
154 |
+
# 执行异步分析
|
155 |
+
results = await analyzer.analyze_images(
|
156 |
+
images=keyframe_files,
|
157 |
+
prompt=config.app.get('vision_analysis_prompt'),
|
158 |
+
batch_size=vision_batch_size
|
159 |
+
)
|
160 |
+
|
161 |
+
progress_callback(60, "正在整理分析结果...")
|
162 |
+
|
163 |
+
# 合并所有批次的分析结果
|
164 |
+
frame_analysis = ""
|
165 |
+
prev_batch_files = None
|
166 |
+
|
167 |
+
for result in results:
|
168 |
+
if 'error' in result:
|
169 |
+
logger.warning(f"批次 {result['batch_index']} 处理出现警告: {result['error']}")
|
170 |
+
continue
|
171 |
+
|
172 |
+
batch_files = self._get_batch_files(keyframe_files, result, vision_batch_size)
|
173 |
+
first_timestamp, last_timestamp, _ = self._get_batch_timestamps(batch_files, prev_batch_files)
|
174 |
+
|
175 |
+
# 添加带时间戳的分��结果
|
176 |
+
frame_analysis += f"\n=== {first_timestamp}-{last_timestamp} ===\n"
|
177 |
+
frame_analysis += result['response']
|
178 |
+
frame_analysis += "\n"
|
179 |
+
|
180 |
+
prev_batch_files = batch_files
|
181 |
+
|
182 |
+
if not frame_analysis.strip():
|
183 |
+
raise Exception("未能生成有效的帧分析结果")
|
184 |
+
|
185 |
+
progress_callback(70, "正在生成脚本...")
|
186 |
+
|
187 |
+
# 构建帧内容列表
|
188 |
+
frame_content_list = []
|
189 |
+
prev_batch_files = None
|
190 |
+
|
191 |
+
for result in results:
|
192 |
+
if 'error' in result:
|
193 |
+
continue
|
194 |
+
|
195 |
+
batch_files = self._get_batch_files(keyframe_files, result, vision_batch_size)
|
196 |
+
_, _, timestamp_range = self._get_batch_timestamps(batch_files, prev_batch_files)
|
197 |
+
|
198 |
+
frame_content = {
|
199 |
+
"timestamp": timestamp_range,
|
200 |
+
"picture": result['response'],
|
201 |
+
"narration": "",
|
202 |
+
"OST": 2
|
203 |
+
}
|
204 |
+
frame_content_list.append(frame_content)
|
205 |
+
prev_batch_files = batch_files
|
206 |
+
|
207 |
+
if not frame_content_list:
|
208 |
+
raise Exception("没有有效的帧内容可以处理")
|
209 |
+
|
210 |
+
progress_callback(90, "正在生成文案...")
|
211 |
+
|
212 |
+
# 获取文本生��配置
|
213 |
+
text_provider = config.app.get('text_llm_provider', 'gemini').lower()
|
214 |
+
text_api_key = config.app.get(f'text_{text_provider}_api_key')
|
215 |
+
text_model = config.app.get(f'text_{text_provider}_model_name')
|
216 |
+
|
217 |
+
processor = ScriptProcessor(
|
218 |
+
model_name=text_model,
|
219 |
+
api_key=text_api_key,
|
220 |
+
prompt=custom_prompt,
|
221 |
+
video_theme=video_theme
|
222 |
+
)
|
223 |
+
|
224 |
+
return processor.process_frames(frame_content_list)
|
225 |
+
|
226 |
+
async def _process_with_narrato(
|
227 |
+
self,
|
228 |
+
keyframe_files: List[str],
|
229 |
+
video_theme: str,
|
230 |
+
custom_prompt: str,
|
231 |
+
vision_batch_size: int,
|
232 |
+
progress_callback: Callable[[float, str], None]
|
233 |
+
) -> str:
|
234 |
+
"""使用NarratoAPI处理视频帧"""
|
235 |
+
# 创建临时目录
|
236 |
+
temp_dir = utils.temp_dir("narrato")
|
237 |
+
|
238 |
+
# 打包关键帧
|
239 |
+
progress_callback(30, "正在打包关键帧...")
|
240 |
+
zip_path = os.path.join(temp_dir, f"keyframes_{int(time.time())}.zip")
|
241 |
+
|
242 |
+
try:
|
243 |
+
if not utils.create_zip(keyframe_files, zip_path):
|
244 |
+
raise Exception("打包关键帧失败")
|
245 |
+
|
246 |
+
# 获取API配置
|
247 |
+
api_url = config.app.get("narrato_api_url")
|
248 |
+
api_key = config.app.get("narrato_api_key")
|
249 |
+
|
250 |
+
if not api_key:
|
251 |
+
raise ValueError("未配置 Narrato API Key")
|
252 |
+
|
253 |
+
headers = {
|
254 |
+
'X-API-Key': api_key,
|
255 |
+
'accept': 'application/json'
|
256 |
+
}
|
257 |
+
|
258 |
+
api_params = {
|
259 |
+
'batch_size': vision_batch_size,
|
260 |
+
'use_ai': False,
|
261 |
+
'start_offset': 0,
|
262 |
+
'vision_model': config.app.get('narrato_vision_model', 'gemini-1.5-flash'),
|
263 |
+
'vision_api_key': config.app.get('narrato_vision_key'),
|
264 |
+
'llm_model': config.app.get('narrato_llm_model', 'qwen-plus'),
|
265 |
+
'llm_api_key': config.app.get('narrato_llm_key'),
|
266 |
+
'custom_prompt': custom_prompt
|
267 |
+
}
|
268 |
+
|
269 |
+
progress_callback(40, "正在上传文件...")
|
270 |
+
with open(zip_path, 'rb') as f:
|
271 |
+
files = {'file': (os.path.basename(zip_path), f, 'application/x-zip-compressed')}
|
272 |
+
response = requests.post(
|
273 |
+
f"{api_url}/video/analyze",
|
274 |
+
headers=headers,
|
275 |
+
params=api_params,
|
276 |
+
files=files,
|
277 |
+
timeout=30
|
278 |
+
)
|
279 |
+
response.raise_for_status()
|
280 |
+
|
281 |
+
task_data = response.json()
|
282 |
+
task_id = task_data["data"].get('task_id')
|
283 |
+
if not task_id:
|
284 |
+
raise Exception(f"无效的API��应: {response.text}")
|
285 |
+
|
286 |
+
progress_callback(50, "正在等待分析结果...")
|
287 |
+
retry_count = 0
|
288 |
+
max_retries = 60
|
289 |
+
|
290 |
+
while retry_count < max_retries:
|
291 |
+
try:
|
292 |
+
status_response = requests.get(
|
293 |
+
f"{api_url}/video/tasks/{task_id}",
|
294 |
+
headers=headers,
|
295 |
+
timeout=10
|
296 |
+
)
|
297 |
+
status_response.raise_for_status()
|
298 |
+
task_status = status_response.json()['data']
|
299 |
+
|
300 |
+
if task_status['status'] == 'SUCCESS':
|
301 |
+
return task_status['result']['data']
|
302 |
+
elif task_status['status'] in ['FAILURE', 'RETRY']:
|
303 |
+
raise Exception(f"任务失败: {task_status.get('error')}")
|
304 |
+
|
305 |
+
retry_count += 1
|
306 |
+
time.sleep(2)
|
307 |
+
|
308 |
+
except requests.RequestException as e:
|
309 |
+
logger.warning(f"获取任务状态失败,重试中: {str(e)}")
|
310 |
+
retry_count += 1
|
311 |
+
time.sleep(2)
|
312 |
+
continue
|
313 |
+
|
314 |
+
raise Exception("任务执行超时")
|
315 |
+
|
316 |
+
finally:
|
317 |
+
# 清理临时文件
|
318 |
+
try:
|
319 |
+
if os.path.exists(zip_path):
|
320 |
+
os.remove(zip_path)
|
321 |
+
except Exception as e:
|
322 |
+
logger.warning(f"清理临时文件失败: {str(e)}")
|
323 |
+
|
324 |
+
def _get_batch_files(
|
325 |
+
self,
|
326 |
+
keyframe_files: List[str],
|
327 |
+
result: Dict[str, Any],
|
328 |
+
batch_size: int
|
329 |
+
) -> List[str]:
|
330 |
+
"""获取当前批次的图片文件"""
|
331 |
+
batch_start = result['batch_index'] * batch_size
|
332 |
+
batch_end = min(batch_start + batch_size, len(keyframe_files))
|
333 |
+
return keyframe_files[batch_start:batch_end]
|
334 |
+
|
335 |
+
def _get_batch_timestamps(
|
336 |
+
self,
|
337 |
+
batch_files: List[str],
|
338 |
+
prev_batch_files: List[str] = None
|
339 |
+
) -> tuple[str, str, str]:
|
340 |
+
"""获取一批文件的时间戳范围,支持毫秒级精度"""
|
341 |
+
if not batch_files:
|
342 |
+
logger.warning("Empty batch files")
|
343 |
+
return "00:00:00,000", "00:00:00,000", "00:00:00,000-00:00:00,000"
|
344 |
+
|
345 |
+
if len(batch_files) == 1 and prev_batch_files and len(prev_batch_files) > 0:
|
346 |
+
first_frame = os.path.basename(prev_batch_files[-1])
|
347 |
+
last_frame = os.path.basename(batch_files[0])
|
348 |
+
else:
|
349 |
+
first_frame = os.path.basename(batch_files[0])
|
350 |
+
last_frame = os.path.basename(batch_files[-1])
|
351 |
+
|
352 |
+
first_time = first_frame.split('_')[2].replace('.jpg', '')
|
353 |
+
last_time = last_frame.split('_')[2].replace('.jpg', '')
|
354 |
+
|
355 |
+
def format_timestamp(time_str: str) -> str:
|
356 |
+
"""将时间字符串转换为 HH:MM:SS,mmm 格式"""
|
357 |
+
try:
|
358 |
+
if len(time_str) < 4:
|
359 |
+
logger.warning(f"Invalid timestamp format: {time_str}")
|
360 |
+
return "00:00:00,000"
|
361 |
+
|
362 |
+
# 处理毫秒部分
|
363 |
+
if ',' in time_str:
|
364 |
+
time_part, ms_part = time_str.split(',')
|
365 |
+
ms = int(ms_part)
|
366 |
+
else:
|
367 |
+
time_part = time_str
|
368 |
+
ms = 0
|
369 |
+
|
370 |
+
# 处理时分秒
|
371 |
+
parts = time_part.split(':')
|
372 |
+
if len(parts) == 3: # HH:MM:SS
|
373 |
+
h, m, s = map(int, parts)
|
374 |
+
elif len(parts) == 2: # MM:SS
|
375 |
+
h = 0
|
376 |
+
m, s = map(int, parts)
|
377 |
+
else: # SS
|
378 |
+
h = 0
|
379 |
+
m = 0
|
380 |
+
s = int(parts[0])
|
381 |
+
|
382 |
+
# 处理进位
|
383 |
+
if s >= 60:
|
384 |
+
m += s // 60
|
385 |
+
s = s % 60
|
386 |
+
if m >= 60:
|
387 |
+
h += m // 60
|
388 |
+
m = m % 60
|
389 |
+
|
390 |
+
return f"{h:02d}:{m:02d}:{s:02d},{ms:03d}"
|
391 |
+
|
392 |
+
except Exception as e:
|
393 |
+
logger.error(f"时间戳格式转换错误 {time_str}: {str(e)}")
|
394 |
+
return "00:00:00,000"
|
395 |
+
|
396 |
+
first_timestamp = format_timestamp(first_time)
|
397 |
+
last_timestamp = format_timestamp(last_time)
|
398 |
+
timestamp_range = f"{first_timestamp}-{last_timestamp}"
|
399 |
+
|
400 |
+
return first_timestamp, last_timestamp, timestamp_range
|
app/services/state.py
ADDED
@@ -0,0 +1,122 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import ast
|
2 |
+
from abc import ABC, abstractmethod
|
3 |
+
from app.config import config
|
4 |
+
from app.models import const
|
5 |
+
|
6 |
+
|
7 |
+
# Base class for state management
|
8 |
+
class BaseState(ABC):
|
9 |
+
@abstractmethod
|
10 |
+
def update_task(self, task_id: str, state: int, progress: int = 0, **kwargs):
|
11 |
+
pass
|
12 |
+
|
13 |
+
@abstractmethod
|
14 |
+
def get_task(self, task_id: str):
|
15 |
+
pass
|
16 |
+
|
17 |
+
|
18 |
+
# Memory state management
|
19 |
+
class MemoryState(BaseState):
|
20 |
+
def __init__(self):
|
21 |
+
self._tasks = {}
|
22 |
+
|
23 |
+
def update_task(
|
24 |
+
self,
|
25 |
+
task_id: str,
|
26 |
+
state: int = const.TASK_STATE_PROCESSING,
|
27 |
+
progress: int = 0,
|
28 |
+
**kwargs,
|
29 |
+
):
|
30 |
+
progress = int(progress)
|
31 |
+
if progress > 100:
|
32 |
+
progress = 100
|
33 |
+
|
34 |
+
self._tasks[task_id] = {
|
35 |
+
"state": state,
|
36 |
+
"progress": progress,
|
37 |
+
**kwargs,
|
38 |
+
}
|
39 |
+
|
40 |
+
def get_task(self, task_id: str):
|
41 |
+
return self._tasks.get(task_id, None)
|
42 |
+
|
43 |
+
def delete_task(self, task_id: str):
|
44 |
+
if task_id in self._tasks:
|
45 |
+
del self._tasks[task_id]
|
46 |
+
|
47 |
+
|
48 |
+
# Redis state management
|
49 |
+
class RedisState(BaseState):
|
50 |
+
def __init__(self, host="localhost", port=6379, db=0, password=None):
|
51 |
+
import redis
|
52 |
+
|
53 |
+
self._redis = redis.StrictRedis(host=host, port=port, db=db, password=password)
|
54 |
+
|
55 |
+
def update_task(
|
56 |
+
self,
|
57 |
+
task_id: str,
|
58 |
+
state: int = const.TASK_STATE_PROCESSING,
|
59 |
+
progress: int = 0,
|
60 |
+
**kwargs,
|
61 |
+
):
|
62 |
+
progress = int(progress)
|
63 |
+
if progress > 100:
|
64 |
+
progress = 100
|
65 |
+
|
66 |
+
fields = {
|
67 |
+
"state": state,
|
68 |
+
"progress": progress,
|
69 |
+
**kwargs,
|
70 |
+
}
|
71 |
+
|
72 |
+
for field, value in fields.items():
|
73 |
+
self._redis.hset(task_id, field, str(value))
|
74 |
+
|
75 |
+
def get_task(self, task_id: str):
|
76 |
+
task_data = self._redis.hgetall(task_id)
|
77 |
+
if not task_data:
|
78 |
+
return None
|
79 |
+
|
80 |
+
task = {
|
81 |
+
key.decode("utf-8"): self._convert_to_original_type(value)
|
82 |
+
for key, value in task_data.items()
|
83 |
+
}
|
84 |
+
return task
|
85 |
+
|
86 |
+
def delete_task(self, task_id: str):
|
87 |
+
self._redis.delete(task_id)
|
88 |
+
|
89 |
+
@staticmethod
|
90 |
+
def _convert_to_original_type(value):
|
91 |
+
"""
|
92 |
+
Convert the value from byte string to its original data type.
|
93 |
+
You can extend this method to handle other data types as needed.
|
94 |
+
"""
|
95 |
+
value_str = value.decode("utf-8")
|
96 |
+
|
97 |
+
try:
|
98 |
+
# try to convert byte string array to list
|
99 |
+
return ast.literal_eval(value_str)
|
100 |
+
except (ValueError, SyntaxError):
|
101 |
+
pass
|
102 |
+
|
103 |
+
if value_str.isdigit():
|
104 |
+
return int(value_str)
|
105 |
+
# Add more conversions here if needed
|
106 |
+
return value_str
|
107 |
+
|
108 |
+
|
109 |
+
# Global state
|
110 |
+
_enable_redis = config.app.get("enable_redis", False)
|
111 |
+
_redis_host = config.app.get("redis_host", "localhost")
|
112 |
+
_redis_port = config.app.get("redis_port", 6379)
|
113 |
+
_redis_db = config.app.get("redis_db", 0)
|
114 |
+
_redis_password = config.app.get("redis_password", None)
|
115 |
+
|
116 |
+
state = (
|
117 |
+
RedisState(
|
118 |
+
host=_redis_host, port=_redis_port, db=_redis_db, password=_redis_password
|
119 |
+
)
|
120 |
+
if _enable_redis
|
121 |
+
else MemoryState()
|
122 |
+
)
|
app/services/subtitle.py
ADDED
@@ -0,0 +1,462 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import json
|
2 |
+
import os.path
|
3 |
+
import re
|
4 |
+
import traceback
|
5 |
+
from typing import Optional
|
6 |
+
|
7 |
+
# from faster_whisper import WhisperModel
|
8 |
+
from timeit import default_timer as timer
|
9 |
+
from loguru import logger
|
10 |
+
import google.generativeai as genai
|
11 |
+
from moviepy import VideoFileClip
|
12 |
+
import os
|
13 |
+
|
14 |
+
from app.config import config
|
15 |
+
from app.utils import utils
|
16 |
+
|
17 |
+
model_size = config.whisper.get("model_size", "faster-whisper-large-v2")
|
18 |
+
device = config.whisper.get("device", "cpu")
|
19 |
+
compute_type = config.whisper.get("compute_type", "int8")
|
20 |
+
model = None
|
21 |
+
|
22 |
+
|
23 |
+
def create(audio_file, subtitle_file: str = ""):
|
24 |
+
"""
|
25 |
+
为给定的音频文件创建字幕文件。
|
26 |
+
|
27 |
+
参数:
|
28 |
+
- audio_file: 音频文件的路径。
|
29 |
+
- subtitle_file: 字幕文件的输出路径(可选)。如果未提供,将根据音频文件的路径生成字幕文件。
|
30 |
+
|
31 |
+
返回:
|
32 |
+
无返回值,但会在指定路径生成字幕文件。
|
33 |
+
"""
|
34 |
+
global model, device, compute_type
|
35 |
+
if not model:
|
36 |
+
model_path = f"{utils.root_dir()}/app/models/faster-whisper-large-v3"
|
37 |
+
model_bin_file = f"{model_path}/model.bin"
|
38 |
+
if not os.path.isdir(model_path) or not os.path.isfile(model_bin_file):
|
39 |
+
logger.error(
|
40 |
+
"请先下载 whisper 模型\n\n"
|
41 |
+
"********************************************\n"
|
42 |
+
"下载地址:https://huggingface.co/guillaumekln/faster-whisper-large-v2\n"
|
43 |
+
"存放路径:app/models \n"
|
44 |
+
"********************************************\n"
|
45 |
+
)
|
46 |
+
return None
|
47 |
+
|
48 |
+
# 首先使用CPU模式,不触发CUDA检查
|
49 |
+
use_cuda = False
|
50 |
+
try:
|
51 |
+
# 在函数中延迟导入torch,而不是在全局范围内
|
52 |
+
# 使用安全的方式检查CUDA可用性
|
53 |
+
def check_cuda_available():
|
54 |
+
try:
|
55 |
+
import torch
|
56 |
+
return torch.cuda.is_available()
|
57 |
+
except (ImportError, RuntimeError) as e:
|
58 |
+
logger.warning(f"检查CUDA可用性时出错: {e}")
|
59 |
+
return False
|
60 |
+
|
61 |
+
# 仅当明确需要时才检查CUDA
|
62 |
+
use_cuda = check_cuda_available()
|
63 |
+
|
64 |
+
if use_cuda:
|
65 |
+
logger.info(f"尝试使用 CUDA 加载模型: {model_path}")
|
66 |
+
try:
|
67 |
+
model = WhisperModel(
|
68 |
+
model_size_or_path=model_path,
|
69 |
+
device="cuda",
|
70 |
+
compute_type="float16",
|
71 |
+
local_files_only=True
|
72 |
+
)
|
73 |
+
device = "cuda"
|
74 |
+
compute_type = "float16"
|
75 |
+
logger.info("成功使用 CUDA 加载模型")
|
76 |
+
except Exception as e:
|
77 |
+
logger.warning(f"CUDA 加载失败,错误信息: {str(e)}")
|
78 |
+
logger.warning("回退到 CPU 模式")
|
79 |
+
use_cuda = False
|
80 |
+
else:
|
81 |
+
logger.info("使用 CPU 模式")
|
82 |
+
except Exception as e:
|
83 |
+
logger.warning(f"CUDA检查过程出错: {e}")
|
84 |
+
logger.warning("默认使用CPU模式")
|
85 |
+
use_cuda = False
|
86 |
+
|
87 |
+
# 如果CUDA不可用或加载失败,使用CPU
|
88 |
+
if not use_cuda:
|
89 |
+
device = "cpu"
|
90 |
+
compute_type = "int8"
|
91 |
+
logger.info(f"使用 CPU 加载模型: {model_path}")
|
92 |
+
model = WhisperModel(
|
93 |
+
model_size_or_path=model_path,
|
94 |
+
device=device,
|
95 |
+
compute_type=compute_type,
|
96 |
+
local_files_only=True
|
97 |
+
)
|
98 |
+
|
99 |
+
logger.info(f"模型加载完成,使用设备: {device}, 计算类型: {compute_type}")
|
100 |
+
|
101 |
+
logger.info(f"start, output file: {subtitle_file}")
|
102 |
+
if not subtitle_file:
|
103 |
+
subtitle_file = f"{audio_file}.srt"
|
104 |
+
|
105 |
+
segments, info = model.transcribe(
|
106 |
+
audio_file,
|
107 |
+
beam_size=5,
|
108 |
+
word_timestamps=True,
|
109 |
+
vad_filter=True,
|
110 |
+
vad_parameters=dict(min_silence_duration_ms=500),
|
111 |
+
initial_prompt="以下是普通话的句子"
|
112 |
+
)
|
113 |
+
|
114 |
+
logger.info(
|
115 |
+
f"检测到的语言: '{info.language}', probability: {info.language_probability:.2f}"
|
116 |
+
)
|
117 |
+
|
118 |
+
start = timer()
|
119 |
+
subtitles = []
|
120 |
+
|
121 |
+
def recognized(seg_text, seg_start, seg_end):
|
122 |
+
seg_text = seg_text.strip()
|
123 |
+
if not seg_text:
|
124 |
+
return
|
125 |
+
|
126 |
+
msg = "[%.2fs -> %.2fs] %s" % (seg_start, seg_end, seg_text)
|
127 |
+
logger.debug(msg)
|
128 |
+
|
129 |
+
subtitles.append(
|
130 |
+
{"msg": seg_text, "start_time": seg_start, "end_time": seg_end}
|
131 |
+
)
|
132 |
+
|
133 |
+
for segment in segments:
|
134 |
+
words_idx = 0
|
135 |
+
words_len = len(segment.words)
|
136 |
+
|
137 |
+
seg_start = 0
|
138 |
+
seg_end = 0
|
139 |
+
seg_text = ""
|
140 |
+
|
141 |
+
if segment.words:
|
142 |
+
is_segmented = False
|
143 |
+
for word in segment.words:
|
144 |
+
if not is_segmented:
|
145 |
+
seg_start = word.start
|
146 |
+
is_segmented = True
|
147 |
+
|
148 |
+
seg_end = word.end
|
149 |
+
# 如果包含标点,则断句
|
150 |
+
seg_text += word.word
|
151 |
+
|
152 |
+
if utils.str_contains_punctuation(word.word):
|
153 |
+
# remove last char
|
154 |
+
seg_text = seg_text[:-1]
|
155 |
+
if not seg_text:
|
156 |
+
continue
|
157 |
+
|
158 |
+
recognized(seg_text, seg_start, seg_end)
|
159 |
+
|
160 |
+
is_segmented = False
|
161 |
+
seg_text = ""
|
162 |
+
|
163 |
+
if words_idx == 0 and segment.start < word.start:
|
164 |
+
seg_start = word.start
|
165 |
+
if words_idx == (words_len - 1) and segment.end > word.end:
|
166 |
+
seg_end = word.end
|
167 |
+
words_idx += 1
|
168 |
+
|
169 |
+
if not seg_text:
|
170 |
+
continue
|
171 |
+
|
172 |
+
recognized(seg_text, seg_start, seg_end)
|
173 |
+
|
174 |
+
end = timer()
|
175 |
+
|
176 |
+
diff = end - start
|
177 |
+
logger.info(f"complete, elapsed: {diff:.2f} s")
|
178 |
+
|
179 |
+
idx = 1
|
180 |
+
lines = []
|
181 |
+
for subtitle in subtitles:
|
182 |
+
text = subtitle.get("msg")
|
183 |
+
if text:
|
184 |
+
lines.append(
|
185 |
+
utils.text_to_srt(
|
186 |
+
idx, text, subtitle.get("start_time"), subtitle.get("end_time")
|
187 |
+
)
|
188 |
+
)
|
189 |
+
idx += 1
|
190 |
+
|
191 |
+
sub = "\n".join(lines) + "\n"
|
192 |
+
with open(subtitle_file, "w", encoding="utf-8") as f:
|
193 |
+
f.write(sub)
|
194 |
+
logger.info(f"subtitle file created: {subtitle_file}")
|
195 |
+
|
196 |
+
|
197 |
+
def file_to_subtitles(filename):
|
198 |
+
"""
|
199 |
+
将字幕文件转换为字幕列表。
|
200 |
+
|
201 |
+
参数:
|
202 |
+
filename (str): 字幕文件的路径。
|
203 |
+
|
204 |
+
返回:
|
205 |
+
list: 包含字幕序号、出现时间、和字幕文本的元组列表。
|
206 |
+
"""
|
207 |
+
if not filename or not os.path.isfile(filename):
|
208 |
+
return []
|
209 |
+
|
210 |
+
times_texts = []
|
211 |
+
current_times = None
|
212 |
+
current_text = ""
|
213 |
+
index = 0
|
214 |
+
with open(filename, "r", encoding="utf-8") as f:
|
215 |
+
for line in f:
|
216 |
+
times = re.findall("([0-9]*:[0-9]*:[0-9]*,[0-9]*)", line)
|
217 |
+
if times:
|
218 |
+
current_times = line
|
219 |
+
elif line.strip() == "" and current_times:
|
220 |
+
index += 1
|
221 |
+
times_texts.append((index, current_times.strip(), current_text.strip()))
|
222 |
+
current_times, current_text = None, ""
|
223 |
+
elif current_times:
|
224 |
+
current_text += line
|
225 |
+
return times_texts
|
226 |
+
|
227 |
+
|
228 |
+
def levenshtein_distance(s1, s2):
|
229 |
+
if len(s1) < len(s2):
|
230 |
+
return levenshtein_distance(s2, s1)
|
231 |
+
|
232 |
+
if len(s2) == 0:
|
233 |
+
return len(s1)
|
234 |
+
|
235 |
+
previous_row = range(len(s2) + 1)
|
236 |
+
for i, c1 in enumerate(s1):
|
237 |
+
current_row = [i + 1]
|
238 |
+
for j, c2 in enumerate(s2):
|
239 |
+
insertions = previous_row[j + 1] + 1
|
240 |
+
deletions = current_row[j] + 1
|
241 |
+
substitutions = previous_row[j] + (c1 != c2)
|
242 |
+
current_row.append(min(insertions, deletions, substitutions))
|
243 |
+
previous_row = current_row
|
244 |
+
|
245 |
+
return previous_row[-1]
|
246 |
+
|
247 |
+
|
248 |
+
def similarity(a, b):
|
249 |
+
distance = levenshtein_distance(a.lower(), b.lower())
|
250 |
+
max_length = max(len(a), len(b))
|
251 |
+
return 1 - (distance / max_length)
|
252 |
+
|
253 |
+
|
254 |
+
def correct(subtitle_file, video_script):
|
255 |
+
subtitle_items = file_to_subtitles(subtitle_file)
|
256 |
+
script_lines = utils.split_string_by_punctuations(video_script)
|
257 |
+
|
258 |
+
corrected = False
|
259 |
+
new_subtitle_items = []
|
260 |
+
script_index = 0
|
261 |
+
subtitle_index = 0
|
262 |
+
|
263 |
+
while script_index < len(script_lines) and subtitle_index < len(subtitle_items):
|
264 |
+
script_line = script_lines[script_index].strip()
|
265 |
+
subtitle_line = subtitle_items[subtitle_index][2].strip()
|
266 |
+
|
267 |
+
if script_line == subtitle_line:
|
268 |
+
new_subtitle_items.append(subtitle_items[subtitle_index])
|
269 |
+
script_index += 1
|
270 |
+
subtitle_index += 1
|
271 |
+
else:
|
272 |
+
combined_subtitle = subtitle_line
|
273 |
+
start_time = subtitle_items[subtitle_index][1].split(" --> ")[0]
|
274 |
+
end_time = subtitle_items[subtitle_index][1].split(" --> ")[1]
|
275 |
+
next_subtitle_index = subtitle_index + 1
|
276 |
+
|
277 |
+
while next_subtitle_index < len(subtitle_items):
|
278 |
+
next_subtitle = subtitle_items[next_subtitle_index][2].strip()
|
279 |
+
if similarity(
|
280 |
+
script_line, combined_subtitle + " " + next_subtitle
|
281 |
+
) > similarity(script_line, combined_subtitle):
|
282 |
+
combined_subtitle += " " + next_subtitle
|
283 |
+
end_time = subtitle_items[next_subtitle_index][1].split(" --> ")[1]
|
284 |
+
next_subtitle_index += 1
|
285 |
+
else:
|
286 |
+
break
|
287 |
+
|
288 |
+
if similarity(script_line, combined_subtitle) > 0.8:
|
289 |
+
logger.warning(
|
290 |
+
f"Merged/Corrected - Script: {script_line}, Subtitle: {combined_subtitle}"
|
291 |
+
)
|
292 |
+
new_subtitle_items.append(
|
293 |
+
(
|
294 |
+
len(new_subtitle_items) + 1,
|
295 |
+
f"{start_time} --> {end_time}",
|
296 |
+
script_line,
|
297 |
+
)
|
298 |
+
)
|
299 |
+
corrected = True
|
300 |
+
else:
|
301 |
+
logger.warning(
|
302 |
+
f"Mismatch - Script: {script_line}, Subtitle: {combined_subtitle}"
|
303 |
+
)
|
304 |
+
new_subtitle_items.append(
|
305 |
+
(
|
306 |
+
len(new_subtitle_items) + 1,
|
307 |
+
f"{start_time} --> {end_time}",
|
308 |
+
script_line,
|
309 |
+
)
|
310 |
+
)
|
311 |
+
corrected = True
|
312 |
+
|
313 |
+
script_index += 1
|
314 |
+
subtitle_index = next_subtitle_index
|
315 |
+
|
316 |
+
# 处理剩余的脚本行
|
317 |
+
while script_index < len(script_lines):
|
318 |
+
logger.warning(f"Extra script line: {script_lines[script_index]}")
|
319 |
+
if subtitle_index < len(subtitle_items):
|
320 |
+
new_subtitle_items.append(
|
321 |
+
(
|
322 |
+
len(new_subtitle_items) + 1,
|
323 |
+
subtitle_items[subtitle_index][1],
|
324 |
+
script_lines[script_index],
|
325 |
+
)
|
326 |
+
)
|
327 |
+
subtitle_index += 1
|
328 |
+
else:
|
329 |
+
new_subtitle_items.append(
|
330 |
+
(
|
331 |
+
len(new_subtitle_items) + 1,
|
332 |
+
"00:00:00,000 --> 00:00:00,000",
|
333 |
+
script_lines[script_index],
|
334 |
+
)
|
335 |
+
)
|
336 |
+
script_index += 1
|
337 |
+
corrected = True
|
338 |
+
|
339 |
+
if corrected:
|
340 |
+
with open(subtitle_file, "w", encoding="utf-8") as fd:
|
341 |
+
for i, item in enumerate(new_subtitle_items):
|
342 |
+
fd.write(f"{i + 1}\n{item[1]}\n{item[2]}\n\n")
|
343 |
+
logger.info("Subtitle corrected")
|
344 |
+
else:
|
345 |
+
logger.success("Subtitle is correct")
|
346 |
+
|
347 |
+
|
348 |
+
def create_with_gemini(audio_file: str, subtitle_file: str = "", api_key: Optional[str] = None) -> Optional[str]:
|
349 |
+
if not api_key:
|
350 |
+
logger.error("Gemini API key is not provided")
|
351 |
+
return None
|
352 |
+
|
353 |
+
genai.configure(api_key=api_key)
|
354 |
+
|
355 |
+
logger.info(f"开始使用Gemini模型处理音频文件: {audio_file}")
|
356 |
+
|
357 |
+
model = genai.GenerativeModel(model_name="gemini-1.5-flash")
|
358 |
+
prompt = "生成这段语音的转录文本。请以SRT格式输出,包含时间戳。"
|
359 |
+
|
360 |
+
try:
|
361 |
+
with open(audio_file, "rb") as f:
|
362 |
+
audio_data = f.read()
|
363 |
+
|
364 |
+
response = model.generate_content([prompt, audio_data])
|
365 |
+
transcript = response.text
|
366 |
+
|
367 |
+
if not subtitle_file:
|
368 |
+
subtitle_file = f"{audio_file}.srt"
|
369 |
+
|
370 |
+
with open(subtitle_file, "w", encoding="utf-8") as f:
|
371 |
+
f.write(transcript)
|
372 |
+
|
373 |
+
logger.info(f"Gemini生成的字幕文件已保存: {subtitle_file}")
|
374 |
+
return subtitle_file
|
375 |
+
except Exception as e:
|
376 |
+
logger.error(f"使用Gemini处理音频时出错: {e}")
|
377 |
+
return None
|
378 |
+
|
379 |
+
|
380 |
+
def extract_audio_and_create_subtitle(video_file: str, subtitle_file: str = "") -> Optional[str]:
|
381 |
+
"""
|
382 |
+
从视频文件中提取音频并生成字幕文件。
|
383 |
+
|
384 |
+
参数:
|
385 |
+
- video_file: MP4视频文件的路径
|
386 |
+
- subtitle_file: 输出字幕文件的路径(可选)。如果未提供,将根据视频文件名自动生成。
|
387 |
+
|
388 |
+
返回:
|
389 |
+
- str: 生成的字幕文件路径
|
390 |
+
- None: 如果处理过程中出现错误
|
391 |
+
"""
|
392 |
+
try:
|
393 |
+
# 获取视频文件所在目录
|
394 |
+
video_dir = os.path.dirname(video_file)
|
395 |
+
video_name = os.path.splitext(os.path.basename(video_file))[0]
|
396 |
+
|
397 |
+
# 设置音频文件路径
|
398 |
+
audio_file = os.path.join(video_dir, f"{video_name}_audio.wav")
|
399 |
+
|
400 |
+
# 如果未指定字幕文件路径,则自动生成
|
401 |
+
if not subtitle_file:
|
402 |
+
subtitle_file = os.path.join(video_dir, f"{video_name}.srt")
|
403 |
+
|
404 |
+
logger.info(f"开始从视频提取音频: {video_file}")
|
405 |
+
|
406 |
+
# 加载视频文件
|
407 |
+
video = VideoFileClip(video_file)
|
408 |
+
|
409 |
+
# 提取音频并保存为WAV格式
|
410 |
+
logger.info(f"正在提取音频到: {audio_file}")
|
411 |
+
video.audio.write_audiofile(audio_file, codec='pcm_s16le')
|
412 |
+
|
413 |
+
# 关闭视频文件
|
414 |
+
video.close()
|
415 |
+
|
416 |
+
logger.info("音频提取完成,开始生成字幕")
|
417 |
+
|
418 |
+
# 使用create函数生成字幕
|
419 |
+
create("/Users/apple/Desktop/WhisperX-zhuanlu/1_qyn2-2_Vocals.wav", subtitle_file)
|
420 |
+
|
421 |
+
# 删除临时音频文件
|
422 |
+
if os.path.exists(audio_file):
|
423 |
+
os.remove(audio_file)
|
424 |
+
logger.info("已清理临时音频文件")
|
425 |
+
|
426 |
+
return subtitle_file
|
427 |
+
|
428 |
+
except Exception as e:
|
429 |
+
logger.error(f"处理视频文件时出错: {str(e)}")
|
430 |
+
logger.error(traceback.format_exc())
|
431 |
+
return None
|
432 |
+
|
433 |
+
|
434 |
+
if __name__ == "__main__":
|
435 |
+
task_id = "123456"
|
436 |
+
task_dir = utils.task_dir(task_id)
|
437 |
+
subtitle_file = f"{task_dir}/subtitle_123456.srt"
|
438 |
+
audio_file = "/Users/apple/Desktop/WhisperX-zhuanlu/1_qyn2-2_Vocals.wav"
|
439 |
+
video_file = "/Users/apple/Desktop/home/NarratoAI/storage/temp/merge/qyn2-2-720p.mp4"
|
440 |
+
|
441 |
+
extract_audio_and_create_subtitle(video_file, subtitle_file)
|
442 |
+
|
443 |
+
# subtitles = file_to_subtitles(subtitle_file)
|
444 |
+
# print(subtitles)
|
445 |
+
|
446 |
+
# # script_file = f"{task_dir}/script.json"
|
447 |
+
# # with open(script_file, "r") as f:
|
448 |
+
# # script_content = f.read()
|
449 |
+
# # s = json.loads(script_content)
|
450 |
+
# # script = s.get("script")
|
451 |
+
# #
|
452 |
+
# # correct(subtitle_file, script)
|
453 |
+
|
454 |
+
# subtitle_file = f"{task_dir}/subtitle111.srt"
|
455 |
+
# create(audio_file, subtitle_file)
|
456 |
+
|
457 |
+
# # # 使用Gemini模型处理音频
|
458 |
+
# # gemini_api_key = config.app.get("gemini_api_key") # 请替换为实际的API密钥
|
459 |
+
# # gemini_subtitle_file = create_with_gemini(audio_file, api_key=gemini_api_key)
|
460 |
+
# #
|
461 |
+
# # if gemini_subtitle_file:
|
462 |
+
# # print(f"Gemini生成的字幕文件: {gemini_subtitle_file}")
|
app/services/subtitle_merger.py
ADDED
@@ -0,0 +1,202 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
#!/usr/bin/env python
|
2 |
+
# -*- coding: UTF-8 -*-
|
3 |
+
|
4 |
+
'''
|
5 |
+
@Project: NarratoAI
|
6 |
+
@File : subtitle_merger
|
7 |
+
@Author : viccy
|
8 |
+
@Date : 2025/5/6 下午4:00
|
9 |
+
'''
|
10 |
+
|
11 |
+
import re
|
12 |
+
import os
|
13 |
+
from datetime import datetime, timedelta
|
14 |
+
|
15 |
+
|
16 |
+
def parse_time(time_str):
|
17 |
+
"""解析时间字符串为timedelta对象"""
|
18 |
+
hours, minutes, seconds_ms = time_str.split(':')
|
19 |
+
seconds, milliseconds = seconds_ms.split(',')
|
20 |
+
|
21 |
+
td = timedelta(
|
22 |
+
hours=int(hours),
|
23 |
+
minutes=int(minutes),
|
24 |
+
seconds=int(seconds),
|
25 |
+
milliseconds=int(milliseconds)
|
26 |
+
)
|
27 |
+
return td
|
28 |
+
|
29 |
+
|
30 |
+
def format_time(td):
|
31 |
+
"""将timedelta对象格式化为SRT时间字符串"""
|
32 |
+
total_seconds = int(td.total_seconds())
|
33 |
+
hours = total_seconds // 3600
|
34 |
+
minutes = (total_seconds % 3600) // 60
|
35 |
+
seconds = total_seconds % 60
|
36 |
+
milliseconds = td.microseconds // 1000
|
37 |
+
|
38 |
+
return f"{hours:02d}:{minutes:02d}:{seconds:02d},{milliseconds:03d}"
|
39 |
+
|
40 |
+
|
41 |
+
def parse_edited_time_range(time_range_str):
|
42 |
+
"""从editedTimeRange字符串中提取时间范围"""
|
43 |
+
if not time_range_str:
|
44 |
+
return None, None
|
45 |
+
|
46 |
+
parts = time_range_str.split('-')
|
47 |
+
if len(parts) != 2:
|
48 |
+
return None, None
|
49 |
+
|
50 |
+
start_time_str, end_time_str = parts
|
51 |
+
|
52 |
+
# 将HH:MM:SS格式转换为timedelta
|
53 |
+
start_h, start_m, start_s = map(int, start_time_str.split(':'))
|
54 |
+
end_h, end_m, end_s = map(int, end_time_str.split(':'))
|
55 |
+
|
56 |
+
start_time = timedelta(hours=start_h, minutes=start_m, seconds=start_s)
|
57 |
+
end_time = timedelta(hours=end_h, minutes=end_m, seconds=end_s)
|
58 |
+
|
59 |
+
return start_time, end_time
|
60 |
+
|
61 |
+
|
62 |
+
def merge_subtitle_files(subtitle_items, output_file=None):
|
63 |
+
"""
|
64 |
+
合并多个SRT字幕文件
|
65 |
+
|
66 |
+
参数:
|
67 |
+
subtitle_items: 字典列表,每个字典包含subtitle文件路径和editedTimeRange
|
68 |
+
output_file: 输出文件的路径,如果为None则自动生成
|
69 |
+
|
70 |
+
返回:
|
71 |
+
合并后的字幕文件路径
|
72 |
+
"""
|
73 |
+
# 按照editedTimeRange的开始时间排序
|
74 |
+
sorted_items = sorted(subtitle_items,
|
75 |
+
key=lambda x: parse_edited_time_range(x.get('editedTimeRange', ''))[0] or timedelta())
|
76 |
+
|
77 |
+
merged_subtitles = []
|
78 |
+
subtitle_index = 1
|
79 |
+
|
80 |
+
for item in sorted_items:
|
81 |
+
if not item.get('subtitle') or not os.path.exists(item.get('subtitle')):
|
82 |
+
continue
|
83 |
+
|
84 |
+
# 从editedTimeRange获取起始时间偏移
|
85 |
+
offset_time, _ = parse_edited_time_range(item.get('editedTimeRange', ''))
|
86 |
+
|
87 |
+
if offset_time is None:
|
88 |
+
print(f"警告: 无法从项目 {item.get('_id')} 的editedTimeRange中提取时间范围,跳过该项")
|
89 |
+
continue
|
90 |
+
|
91 |
+
with open(item['subtitle'], 'r', encoding='utf-8') as file:
|
92 |
+
content = file.read()
|
93 |
+
|
94 |
+
# 解析字幕文件
|
95 |
+
subtitle_blocks = re.split(r'\n\s*\n', content.strip())
|
96 |
+
|
97 |
+
for block in subtitle_blocks:
|
98 |
+
lines = block.strip().split('\n')
|
99 |
+
if len(lines) < 3: # 确保块有足够的行数
|
100 |
+
continue
|
101 |
+
|
102 |
+
# 解析时间轴行
|
103 |
+
time_line = lines[1]
|
104 |
+
time_parts = time_line.split(' --> ')
|
105 |
+
if len(time_parts) != 2:
|
106 |
+
continue
|
107 |
+
|
108 |
+
start_time = parse_time(time_parts[0])
|
109 |
+
end_time = parse_time(time_parts[1])
|
110 |
+
|
111 |
+
# 应用时间偏移
|
112 |
+
adjusted_start_time = start_time + offset_time
|
113 |
+
adjusted_end_time = end_time + offset_time
|
114 |
+
|
115 |
+
# 重建字幕块
|
116 |
+
adjusted_time_line = f"{format_time(adjusted_start_time)} --> {format_time(adjusted_end_time)}"
|
117 |
+
text_lines = lines[2:]
|
118 |
+
|
119 |
+
new_block = [
|
120 |
+
str(subtitle_index),
|
121 |
+
adjusted_time_line,
|
122 |
+
*text_lines
|
123 |
+
]
|
124 |
+
|
125 |
+
merged_subtitles.append('\n'.join(new_block))
|
126 |
+
subtitle_index += 1
|
127 |
+
|
128 |
+
# 确定输出文件路径
|
129 |
+
if output_file is None:
|
130 |
+
dir_path = os.path.dirname(sorted_items[0]['subtitle'])
|
131 |
+
first_start = parse_edited_time_range(sorted_items[0]['editedTimeRange'])[0]
|
132 |
+
last_end = parse_edited_time_range(sorted_items[-1]['editedTimeRange'])[1]
|
133 |
+
|
134 |
+
first_start_h, first_start_m, first_start_s = int(first_start.seconds // 3600), int((first_start.seconds % 3600) // 60), int(first_start.seconds % 60)
|
135 |
+
last_end_h, last_end_m, last_end_s = int(last_end.seconds // 3600), int((last_end.seconds % 3600) // 60), int(last_end.seconds % 60)
|
136 |
+
|
137 |
+
first_start_str = f"{first_start_h:02d}_{first_start_m:02d}_{first_start_s:02d}"
|
138 |
+
last_end_str = f"{last_end_h:02d}_{last_end_m:02d}_{last_end_s:02d}"
|
139 |
+
|
140 |
+
output_file = os.path.join(dir_path, f"merged_subtitle_{first_start_str}-{last_end_str}.srt")
|
141 |
+
|
142 |
+
# 合并所有字幕块
|
143 |
+
merged_content = '\n\n'.join(merged_subtitles)
|
144 |
+
|
145 |
+
# 写��合并后的内容
|
146 |
+
with open(output_file, 'w', encoding='utf-8') as file:
|
147 |
+
file.write(merged_content)
|
148 |
+
|
149 |
+
return output_file
|
150 |
+
|
151 |
+
|
152 |
+
if __name__ == '__main__':
|
153 |
+
# 测试数据
|
154 |
+
test_data = [
|
155 |
+
{'picture': '【解说】好的,各位,欢迎回到我的频道!《庆余年 2》刚开播就给了我们一个王炸!范闲在北齐"死"了?这怎么可能!',
|
156 |
+
'timestamp': '00:00:00-00:01:15',
|
157 |
+
'narration': '好的各位,欢迎回到我的频道!《庆余年 2》刚开播就给了我们一个王炸!范闲在北齐"死"了?这怎么可能!上集片尾那个巨大的悬念,这一集就立刻揭晓了!范闲假死归来,他面临的第一个,也是最大的难关,就是如何面对他最敬爱的,同时也是最可怕的那个人——庆帝!',
|
158 |
+
'OST': 0,
|
159 |
+
'_id': 1,
|
160 |
+
'audio': '/Users/apple/Desktop/home/NarratoAI/storage/tasks/qyn2-2-demo/audio_00_00_00-00_01_15.mp3',
|
161 |
+
'subtitle': '/Users/apple/Desktop/home/NarratoAI/storage/tasks/qyn2-2-demo/subtitle_00_00_00-00_01_15.srt',
|
162 |
+
'sourceTimeRange': '00:00:00-00:00:26',
|
163 |
+
'duration': 26,
|
164 |
+
'editedTimeRange': '00:00:00-00:00:26'
|
165 |
+
},
|
166 |
+
{'picture': '【解说】上一集我们看到,范闲在北齐遭遇了惊天变故,生死不明!',
|
167 |
+
'timestamp': '00:01:15-00:04:40',
|
168 |
+
'narration': '但我们都知道,他绝不可能就这么轻易退场!第二集一开场,范闲就已经秘密回到了京都。他的生死传闻,可不像我们想象中那样只是小范围流传,而是…',
|
169 |
+
'OST': 0,
|
170 |
+
'_id': 2,
|
171 |
+
'audio': '/Users/apple/Desktop/home/NarratoAI/storage/tasks/qyn2-2-demo/audio_00_01_15-00_04_40.mp3',
|
172 |
+
'subtitle': '/Users/apple/Desktop/home/NarratoAI/storage/tasks/qyn2-2-demo/subtitle_00_01_15-00_04_40.srt',
|
173 |
+
'sourceTimeRange': '00:01:15-00:01:29',
|
174 |
+
'duration': 14,
|
175 |
+
'editedTimeRange': '00:00:26-00:00:40'
|
176 |
+
},
|
177 |
+
{'picture': '【解说】"欺君之罪"!在封建王朝,这可是抄家灭族的大罪!搁一般人,肯定脚底抹油溜之大吉了。',
|
178 |
+
'timestamp': '00:04:58-00:05:45',
|
179 |
+
'narration': '"欺君之罪"!在封建王朝,这可是抄家灭族的大罪!搁一般人,肯定脚底抹油溜之大吉了。但范闲是谁啊?他偏要反其道而行之!他竟然决定,直接去见庆帝!冒着天大的风险,用"假死"这个事实去赌庆帝的态度!',
|
180 |
+
'OST': 0,
|
181 |
+
'_id': 4,
|
182 |
+
'audio': '/Users/apple/Desktop/home/NarratoAI/storage/tasks/qyn2-2-demo/audio_00_04_58-00_05_45.mp3',
|
183 |
+
'subtitle': '/Users/apple/Desktop/home/NarratoAI/storage/tasks/qyn2-2-demo/subtitle_00_04_58-00_05_45.srt',
|
184 |
+
'sourceTimeRange': '00:04:58-00:05:20',
|
185 |
+
'duration': 22,
|
186 |
+
'editedTimeRange': '00:00:57-00:01:19'
|
187 |
+
},
|
188 |
+
{'picture': '【解说】但想见庆帝,哪有那么容易?范闲艺高人胆大,竟然选择了最激进的方式——闯宫!',
|
189 |
+
'timestamp': '00:05:45-00:06:00',
|
190 |
+
'narration': '但想见庆帝,哪有那么容易?范闲艺高人胆大,竟然选择了最激进的方式——闯宫!',
|
191 |
+
'OST': 0,
|
192 |
+
'_id': 5,
|
193 |
+
'audio': '/Users/apple/Desktop/home/NarratoAI/storage/tasks/qyn2-2-demo/audio_00_05_45-00_06_00.mp3',
|
194 |
+
'subtitle': '/Users/apple/Desktop/home/NarratoAI/storage/tasks/qyn2-2-demo/subtitle_00_05_45-00_06_00.srt',
|
195 |
+
'sourceTimeRange': '00:05:45-00:05:53',
|
196 |
+
'duration': 8,
|
197 |
+
'editedTimeRange': '00:01:19-00:01:27'
|
198 |
+
}
|
199 |
+
]
|
200 |
+
|
201 |
+
output_file = merge_subtitle_files(test_data)
|
202 |
+
print(f"字幕文件已合并至: {output_file}")
|
app/services/task.py
ADDED
@@ -0,0 +1,398 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import math
|
2 |
+
import json
|
3 |
+
import os.path
|
4 |
+
import re
|
5 |
+
import traceback
|
6 |
+
from os import path
|
7 |
+
from loguru import logger
|
8 |
+
|
9 |
+
from app.config import config
|
10 |
+
from app.models import const
|
11 |
+
from app.models.schema import VideoConcatMode, VideoParams, VideoClipParams
|
12 |
+
from app.services import (llm, material, subtitle, video, voice, audio_merger,
|
13 |
+
subtitle_merger, clip_video, merger_video, update_script, generate_video)
|
14 |
+
from app.services import state as sm
|
15 |
+
from app.utils import utils
|
16 |
+
|
17 |
+
|
18 |
+
# def generate_script(task_id, params):
|
19 |
+
# logger.info("\n\n## generating video script")
|
20 |
+
# video_script = params.video_script.strip()
|
21 |
+
# if not video_script:
|
22 |
+
# video_script = llm.generate_script(
|
23 |
+
# video_subject=params.video_subject,
|
24 |
+
# language=params.video_language,
|
25 |
+
# paragraph_number=params.paragraph_number,
|
26 |
+
# )
|
27 |
+
# else:
|
28 |
+
# logger.debug(f"video script: \n{video_script}")
|
29 |
+
|
30 |
+
# if not video_script:
|
31 |
+
# sm.state.update_task(task_id, state=const.TASK_STATE_FAILED)
|
32 |
+
# logger.error("failed to generate video script.")
|
33 |
+
# return None
|
34 |
+
|
35 |
+
# return video_script
|
36 |
+
|
37 |
+
|
38 |
+
# def generate_terms(task_id, params, video_script):
|
39 |
+
# logger.info("\n\n## generating video terms")
|
40 |
+
# video_terms = params.video_terms
|
41 |
+
# if not video_terms:
|
42 |
+
# video_terms = llm.generate_terms(
|
43 |
+
# video_subject=params.video_subject, video_script=video_script, amount=5
|
44 |
+
# )
|
45 |
+
# else:
|
46 |
+
# if isinstance(video_terms, str):
|
47 |
+
# video_terms = [term.strip() for term in re.split(r"[,,]", video_terms)]
|
48 |
+
# elif isinstance(video_terms, list):
|
49 |
+
# video_terms = [term.strip() for term in video_terms]
|
50 |
+
# else:
|
51 |
+
# raise ValueError("video_terms must be a string or a list of strings.")
|
52 |
+
|
53 |
+
# logger.debug(f"video terms: {utils.to_json(video_terms)}")
|
54 |
+
|
55 |
+
# if not video_terms:
|
56 |
+
# sm.state.update_task(task_id, state=const.TASK_STATE_FAILED)
|
57 |
+
# logger.error("failed to generate video terms.")
|
58 |
+
# return None
|
59 |
+
|
60 |
+
# return video_terms
|
61 |
+
|
62 |
+
|
63 |
+
# def save_script_data(task_id, video_script, video_terms, params):
|
64 |
+
# script_file = path.join(utils.task_dir(task_id), "script.json")
|
65 |
+
# script_data = {
|
66 |
+
# "script": video_script,
|
67 |
+
# "search_terms": video_terms,
|
68 |
+
# "params": params,
|
69 |
+
# }
|
70 |
+
|
71 |
+
# with open(script_file, "w", encoding="utf-8") as f:
|
72 |
+
# f.write(utils.to_json(script_data))
|
73 |
+
|
74 |
+
|
75 |
+
# def generate_audio(task_id, params, video_script):
|
76 |
+
# logger.info("\n\n## generating audio")
|
77 |
+
# audio_file = path.join(utils.task_dir(task_id), "audio.mp3")
|
78 |
+
# sub_maker = voice.tts(
|
79 |
+
# text=video_script,
|
80 |
+
# voice_name=voice.parse_voice_name(params.voice_name),
|
81 |
+
# voice_rate=params.voice_rate,
|
82 |
+
# voice_file=audio_file,
|
83 |
+
# )
|
84 |
+
# if sub_maker is None:
|
85 |
+
# sm.state.update_task(task_id, state=const.TASK_STATE_FAILED)
|
86 |
+
# logger.error(
|
87 |
+
# """failed to generate audio:
|
88 |
+
# 1. check if the language of the voice matches the language of the video script.
|
89 |
+
# 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.
|
90 |
+
# """.strip()
|
91 |
+
# )
|
92 |
+
# return None, None, None
|
93 |
+
|
94 |
+
# audio_duration = math.ceil(voice.get_audio_duration(sub_maker))
|
95 |
+
# return audio_file, audio_duration, sub_maker
|
96 |
+
|
97 |
+
|
98 |
+
# def generate_subtitle(task_id, params, video_script, sub_maker, audio_file):
|
99 |
+
# if not params.subtitle_enabled:
|
100 |
+
# return ""
|
101 |
+
|
102 |
+
# subtitle_path = path.join(utils.task_dir(task_id), "subtitle111.srt")
|
103 |
+
# subtitle_provider = config.app.get("subtitle_provider", "").strip().lower()
|
104 |
+
# logger.info(f"\n\n## generating subtitle, provider: {subtitle_provider}")
|
105 |
+
|
106 |
+
# subtitle_fallback = False
|
107 |
+
# if subtitle_provider == "edge":
|
108 |
+
# voice.create_subtitle(
|
109 |
+
# text=video_script, sub_maker=sub_maker, subtitle_file=subtitle_path
|
110 |
+
# )
|
111 |
+
# if not os.path.exists(subtitle_path):
|
112 |
+
# subtitle_fallback = True
|
113 |
+
# logger.warning("subtitle file not found, fallback to whisper")
|
114 |
+
|
115 |
+
# if subtitle_provider == "whisper" or subtitle_fallback:
|
116 |
+
# subtitle.create(audio_file=audio_file, subtitle_file=subtitle_path)
|
117 |
+
# logger.info("\n\n## correcting subtitle")
|
118 |
+
# subtitle.correct(subtitle_file=subtitle_path, video_script=video_script)
|
119 |
+
|
120 |
+
# subtitle_lines = subtitle.file_to_subtitles(subtitle_path)
|
121 |
+
# if not subtitle_lines:
|
122 |
+
# logger.warning(f"subtitle file is invalid: {subtitle_path}")
|
123 |
+
# return ""
|
124 |
+
|
125 |
+
# return subtitle_path
|
126 |
+
|
127 |
+
|
128 |
+
# def get_video_materials(task_id, params, video_terms, audio_duration):
|
129 |
+
# if params.video_source == "local":
|
130 |
+
# logger.info("\n\n## preprocess local materials")
|
131 |
+
# materials = video.preprocess_video(
|
132 |
+
# materials=params.video_materials, clip_duration=params.video_clip_duration
|
133 |
+
# )
|
134 |
+
# if not materials:
|
135 |
+
# sm.state.update_task(task_id, state=const.TASK_STATE_FAILED)
|
136 |
+
# logger.error(
|
137 |
+
# "no valid materials found, please check the materials and try again."
|
138 |
+
# )
|
139 |
+
# return None
|
140 |
+
# return [material_info.url for material_info in materials]
|
141 |
+
# else:
|
142 |
+
# logger.info(f"\n\n## downloading videos from {params.video_source}")
|
143 |
+
# downloaded_videos = material.download_videos(
|
144 |
+
# task_id=task_id,
|
145 |
+
# search_terms=video_terms,
|
146 |
+
# source=params.video_source,
|
147 |
+
# video_aspect=params.video_aspect,
|
148 |
+
# video_contact_mode=params.video_concat_mode,
|
149 |
+
# audio_duration=audio_duration * params.video_count,
|
150 |
+
# max_clip_duration=params.video_clip_duration,
|
151 |
+
# )
|
152 |
+
# if not downloaded_videos:
|
153 |
+
# sm.state.update_task(task_id, state=const.TASK_STATE_FAILED)
|
154 |
+
# logger.error(
|
155 |
+
# "failed to download videos, maybe the network is not available. if you are in China, please use a VPN."
|
156 |
+
# )
|
157 |
+
# return None
|
158 |
+
# return downloaded_videos
|
159 |
+
|
160 |
+
|
161 |
+
def start_subclip(task_id: str, params: VideoClipParams, subclip_path_videos: dict):
|
162 |
+
"""
|
163 |
+
后台任务(自动剪辑视频进行剪辑)
|
164 |
+
Args:
|
165 |
+
task_id: 任务ID
|
166 |
+
params: 视频参数
|
167 |
+
subclip_path_videos: 视频片段路径
|
168 |
+
"""
|
169 |
+
global merged_audio_path, merged_subtitle_path
|
170 |
+
|
171 |
+
logger.info(f"\n\n## 开始任务: {task_id}")
|
172 |
+
sm.state.update_task(task_id, state=const.TASK_STATE_PROCESSING, progress=0)
|
173 |
+
|
174 |
+
# # 初始化 ImageMagick
|
175 |
+
# if not utils.init_imagemagick():
|
176 |
+
# logger.warning("ImageMagick 初始化失败,字幕可能无法正常显示")
|
177 |
+
|
178 |
+
# # tts 角色名称
|
179 |
+
# voice_name = voice.parse_voice_name(params.voice_name)
|
180 |
+
"""
|
181 |
+
1. 加载剪辑脚本
|
182 |
+
"""
|
183 |
+
logger.info("\n\n## 1. 加载视频脚本")
|
184 |
+
video_script_path = path.join(params.video_clip_json_path)
|
185 |
+
|
186 |
+
if path.exists(video_script_path):
|
187 |
+
try:
|
188 |
+
with open(video_script_path, "r", encoding="utf-8") as f:
|
189 |
+
list_script = json.load(f)
|
190 |
+
video_list = [i['narration'] for i in list_script]
|
191 |
+
video_ost = [i['OST'] for i in list_script]
|
192 |
+
time_list = [i['timestamp'] for i in list_script]
|
193 |
+
|
194 |
+
video_script = " ".join(video_list)
|
195 |
+
logger.debug(f"解说完整脚本: \n{video_script}")
|
196 |
+
logger.debug(f"解说 OST 列表: \n{video_ost}")
|
197 |
+
logger.debug(f"解说时间戳列表: \n{time_list}")
|
198 |
+
except Exception as e:
|
199 |
+
logger.error(f"无法读取视频json脚本,请检查脚本格式是否正确")
|
200 |
+
raise ValueError("无法读取视频json脚本,请检查脚本格式是否正确")
|
201 |
+
else:
|
202 |
+
logger.error(f"video_script_path: {video_script_path} \n\n", traceback.format_exc())
|
203 |
+
raise ValueError("解说脚本不存在!请检查配置是否正确。")
|
204 |
+
|
205 |
+
"""
|
206 |
+
2. 使用 TTS 生成音频素材
|
207 |
+
"""
|
208 |
+
logger.info("\n\n## 2. 根据OST设置生成音频列表")
|
209 |
+
# 只为OST=0 or 2的判断生成音频, OST=0 仅保留解说 OST=2 保留解说和原声
|
210 |
+
tts_segments = [
|
211 |
+
segment for segment in list_script
|
212 |
+
if segment['OST'] in [0, 2]
|
213 |
+
]
|
214 |
+
logger.debug(f"需要生成TTS的片段数: {len(tts_segments)}")
|
215 |
+
|
216 |
+
tts_results = voice.tts_multiple(
|
217 |
+
task_id=task_id,
|
218 |
+
list_script=tts_segments, # 只传入需要TTS的片段
|
219 |
+
voice_name=params.voice_name,
|
220 |
+
voice_rate=params.voice_rate,
|
221 |
+
voice_pitch=params.voice_pitch,
|
222 |
+
)
|
223 |
+
|
224 |
+
sm.state.update_task(task_id, state=const.TASK_STATE_PROCESSING, progress=20)
|
225 |
+
|
226 |
+
# """
|
227 |
+
# 3. (可选) 使用 whisper 生成字幕
|
228 |
+
# """
|
229 |
+
# if merged_subtitle_path is None:
|
230 |
+
# if audio_files:
|
231 |
+
# merged_subtitle_path = path.join(utils.task_dir(task_id), f"subtitle.srt")
|
232 |
+
# subtitle_provider = config.app.get("subtitle_provider", "").strip().lower()
|
233 |
+
# logger.info(f"\n\n使用 {subtitle_provider} 生成字幕")
|
234 |
+
#
|
235 |
+
# subtitle.create(
|
236 |
+
# audio_file=merged_audio_path,
|
237 |
+
# subtitle_file=merged_subtitle_path,
|
238 |
+
# )
|
239 |
+
# subtitle_lines = subtitle.file_to_subtitles(merged_subtitle_path)
|
240 |
+
# if not subtitle_lines:
|
241 |
+
# logger.warning(f"字幕文件无效: {merged_subtitle_path}")
|
242 |
+
#
|
243 |
+
# sm.state.update_task(task_id, state=const.TASK_STATE_PROCESSING, progress=40)
|
244 |
+
|
245 |
+
"""
|
246 |
+
3. 裁剪视频 - 将超出音频长度的视频进行裁剪
|
247 |
+
"""
|
248 |
+
logger.info("\n\n## 3. 裁剪视频")
|
249 |
+
video_clip_result = clip_video.clip_video(params.video_origin_path, tts_results)
|
250 |
+
# 更新 list_script 中的时间戳
|
251 |
+
tts_clip_result = {tts_result['_id']: tts_result['audio_file'] for tts_result in tts_results}
|
252 |
+
subclip_clip_result = {
|
253 |
+
tts_result['_id']: tts_result['subtitle_file'] for tts_result in tts_results
|
254 |
+
}
|
255 |
+
new_script_list = update_script.update_script_timestamps(list_script, video_clip_result, tts_clip_result, subclip_clip_result)
|
256 |
+
|
257 |
+
sm.state.update_task(task_id, state=const.TASK_STATE_PROCESSING, progress=60)
|
258 |
+
|
259 |
+
"""
|
260 |
+
4. 合并音频和字幕
|
261 |
+
"""
|
262 |
+
logger.info("\n\n## 4. 合并音频和字幕")
|
263 |
+
total_duration = sum([script["duration"] for script in new_script_list])
|
264 |
+
if tts_segments:
|
265 |
+
try:
|
266 |
+
# 合并音频文件
|
267 |
+
merged_audio_path = audio_merger.merge_audio_files(
|
268 |
+
task_id=task_id,
|
269 |
+
total_duration=total_duration,
|
270 |
+
list_script=new_script_list
|
271 |
+
)
|
272 |
+
logger.info(f"音频文件合并成功->{merged_audio_path}")
|
273 |
+
# 合并字幕文件
|
274 |
+
merged_subtitle_path = subtitle_merger.merge_subtitle_files(new_script_list)
|
275 |
+
logger.info(f"字幕文件合并成功->{merged_subtitle_path}")
|
276 |
+
except Exception as e:
|
277 |
+
logger.error(f"合并音频文件失败: {str(e)}")
|
278 |
+
else:
|
279 |
+
logger.warning("没有需要合并的音频/字幕")
|
280 |
+
merged_audio_path = ""
|
281 |
+
merged_subtitle_path = ""
|
282 |
+
|
283 |
+
"""
|
284 |
+
5. 合并视频
|
285 |
+
"""
|
286 |
+
final_video_paths = []
|
287 |
+
combined_video_paths = []
|
288 |
+
|
289 |
+
combined_video_path = path.join(utils.task_dir(task_id), f"merger.mp4")
|
290 |
+
logger.info(f"\n\n## 5. 合并视频: => {combined_video_path}")
|
291 |
+
# 如果 new_script_list 中没有 video,则使用 subclip_path_videos 中的视频
|
292 |
+
video_clips = [new_script['video'] if new_script.get('video') else subclip_path_videos.get(new_script.get('_id', '')) for new_script in new_script_list]
|
293 |
+
|
294 |
+
merger_video.combine_clip_videos(
|
295 |
+
output_video_path=combined_video_path,
|
296 |
+
video_paths=video_clips,
|
297 |
+
video_ost_list=video_ost,
|
298 |
+
video_aspect=params.video_aspect,
|
299 |
+
threads=params.n_threads
|
300 |
+
)
|
301 |
+
sm.state.update_task(task_id, state=const.TASK_STATE_PROCESSING, progress=80)
|
302 |
+
|
303 |
+
"""
|
304 |
+
6. 合并字幕/BGM/配音/视频
|
305 |
+
"""
|
306 |
+
output_video_path = path.join(utils.task_dir(task_id), f"combined.mp4")
|
307 |
+
logger.info(f"\n\n## 6. 最后一步: 合并字幕/BGM/配音/视频 -> {output_video_path}")
|
308 |
+
|
309 |
+
# bgm_path = '/Users/apple/Desktop/home/NarratoAI/resource/songs/bgm.mp3'
|
310 |
+
bgm_path = utils.get_bgm_file()
|
311 |
+
|
312 |
+
# 调用示例
|
313 |
+
options = {
|
314 |
+
'voice_volume': params.tts_volume, # 配音音量
|
315 |
+
'bgm_volume': params.bgm_volume, # 背景音乐音量
|
316 |
+
'original_audio_volume': params.original_volume, # 视频原声音量,0表示不保留
|
317 |
+
'keep_original_audio': True, # 是否保留原声
|
318 |
+
'subtitle_font': params.font_name, # 这里使用相对字体路径,会自动在 font_dir() 目录下查找
|
319 |
+
'subtitle_font_size': params.font_size,
|
320 |
+
'subtitle_color': params.text_fore_color,
|
321 |
+
'subtitle_bg_color': None, # 直接使用None表示透明背景
|
322 |
+
'subtitle_position': params.subtitle_position,
|
323 |
+
'custom_position': params.custom_position,
|
324 |
+
'threads': params.n_threads
|
325 |
+
}
|
326 |
+
generate_video.merge_materials(
|
327 |
+
video_path=combined_video_path,
|
328 |
+
audio_path=merged_audio_path,
|
329 |
+
subtitle_path=merged_subtitle_path,
|
330 |
+
bgm_path=bgm_path,
|
331 |
+
output_path=output_video_path,
|
332 |
+
options=options
|
333 |
+
)
|
334 |
+
|
335 |
+
final_video_paths.append(output_video_path)
|
336 |
+
combined_video_paths.append(combined_video_path)
|
337 |
+
|
338 |
+
logger.success(f"任务 {task_id} 已完成, 生成 {len(final_video_paths)} 个视频.")
|
339 |
+
|
340 |
+
kwargs = {
|
341 |
+
"videos": final_video_paths,
|
342 |
+
"combined_videos": combined_video_paths
|
343 |
+
}
|
344 |
+
sm.state.update_task(task_id, state=const.TASK_STATE_COMPLETE, progress=100, **kwargs)
|
345 |
+
return kwargs
|
346 |
+
|
347 |
+
|
348 |
+
def validate_params(video_path, audio_path, output_file, params):
|
349 |
+
"""
|
350 |
+
验证输入参数
|
351 |
+
Args:
|
352 |
+
video_path: 视频文件路径
|
353 |
+
audio_path: 音频文件路径(可以为空字符串)
|
354 |
+
output_file: 输出文件路径
|
355 |
+
params: 视频参数
|
356 |
+
|
357 |
+
Raises:
|
358 |
+
FileNotFoundError: 文件不存在时抛出
|
359 |
+
ValueError: 参数无效时抛出
|
360 |
+
"""
|
361 |
+
if not video_path:
|
362 |
+
raise ValueError("视频路径不能为空")
|
363 |
+
if not os.path.exists(video_path):
|
364 |
+
raise FileNotFoundError(f"视频文件不存在: {video_path}")
|
365 |
+
|
366 |
+
# 如果提供了音频路径,则验证文件是否存在
|
367 |
+
if audio_path and not os.path.exists(audio_path):
|
368 |
+
raise FileNotFoundError(f"音频文件不存在: {audio_path}")
|
369 |
+
|
370 |
+
if not output_file:
|
371 |
+
raise ValueError("输出文件路径不能为空")
|
372 |
+
|
373 |
+
# 确保输出目录存在
|
374 |
+
output_dir = os.path.dirname(output_file)
|
375 |
+
if not os.path.exists(output_dir):
|
376 |
+
os.makedirs(output_dir)
|
377 |
+
|
378 |
+
if not params:
|
379 |
+
raise ValueError("视频参数不能为空")
|
380 |
+
|
381 |
+
|
382 |
+
if __name__ == "__main__":
|
383 |
+
task_id = "demo"
|
384 |
+
|
385 |
+
# 提前裁剪是为了方便检��视频
|
386 |
+
subclip_path_videos = {
|
387 |
+
1: '/Users/apple/Desktop/home/NarratoAI/storage/temp/clip_video/113343d127b5a09d0bf84b68bd1b3b97/[email protected]',
|
388 |
+
2: '/Users/apple/Desktop/home/NarratoAI/storage/temp/clip_video/113343d127b5a09d0bf84b68bd1b3b97/[email protected]',
|
389 |
+
3: '/Users/apple/Desktop/home/NarratoAI/storage/temp/clip_video/113343d127b5a09d0bf84b68bd1b3b97/[email protected]',
|
390 |
+
4: '/Users/apple/Desktop/home/NarratoAI/storage/temp/clip_video/113343d127b5a09d0bf84b68bd1b3b97/[email protected]',
|
391 |
+
5: '/Users/apple/Desktop/home/NarratoAI/storage/temp/clip_video/113343d127b5a09d0bf84b68bd1b3b97/[email protected]',
|
392 |
+
}
|
393 |
+
|
394 |
+
params = VideoClipParams(
|
395 |
+
video_clip_json_path="/Users/apple/Desktop/home/NarratoAI/resource/scripts/2025-0507-223311.json",
|
396 |
+
video_origin_path="/Users/apple/Desktop/home/NarratoAI/resource/videos/merged_video_4938.mp4",
|
397 |
+
)
|
398 |
+
start_subclip(task_id, params, subclip_path_videos)
|
app/services/update_script.py
ADDED
@@ -0,0 +1,266 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
#!/usr/bin/env python
|
2 |
+
# -*- coding: UTF-8 -*-
|
3 |
+
|
4 |
+
'''
|
5 |
+
@Project: NarratoAI
|
6 |
+
@File : update_script
|
7 |
+
@Author : 小林同学
|
8 |
+
@Date : 2025/5/6 下午11:00
|
9 |
+
'''
|
10 |
+
|
11 |
+
import re
|
12 |
+
import os
|
13 |
+
from typing import Dict, List, Any, Tuple, Union
|
14 |
+
|
15 |
+
|
16 |
+
def extract_timestamp_from_video_path(video_path: str) -> str:
|
17 |
+
"""
|
18 |
+
从视频文件路径中提取时间戳
|
19 |
+
|
20 |
+
Args:
|
21 |
+
video_path: 视频文件路径
|
22 |
+
|
23 |
+
Returns:
|
24 |
+
提取出的时间戳,格式为 'HH:MM:SS-HH:MM:SS' 或 'HH:MM:SS,sss-HH:MM:SS,sss'
|
25 |
+
"""
|
26 |
+
# 使用正则表达式从文件名中提取时间戳
|
27 |
+
filename = os.path.basename(video_path)
|
28 |
+
|
29 |
+
# 匹配新格式: [email protected]
|
30 |
+
match_new = re.search(r'vid_(\d{2})-(\d{2})-(\d{2})-(\d{3})@(\d{2})-(\d{2})-(\d{2})-(\d{3})\.mp4', filename)
|
31 |
+
if match_new:
|
32 |
+
# 提取并格式化时间戳(包含毫秒)
|
33 |
+
start_h, start_m, start_s, start_ms = match_new.group(1), match_new.group(2), match_new.group(3), match_new.group(4)
|
34 |
+
end_h, end_m, end_s, end_ms = match_new.group(5), match_new.group(6), match_new.group(7), match_new.group(8)
|
35 |
+
return f"{start_h}:{start_m}:{start_s},{start_ms}-{end_h}:{end_m}:{end_s},{end_ms}"
|
36 |
+
|
37 |
+
# 匹配旧格式: vid-00-00-00-00-00-00.mp4
|
38 |
+
match_old = re.search(r'vid-(\d{2}-\d{2}-\d{2})-(\d{2}-\d{2}-\d{2})\.mp4', filename)
|
39 |
+
if match_old:
|
40 |
+
# 提取并格式化时间戳
|
41 |
+
start_time = match_old.group(1).replace('-', ':')
|
42 |
+
end_time = match_old.group(2).replace('-', ':')
|
43 |
+
return f"{start_time}-{end_time}"
|
44 |
+
|
45 |
+
return ""
|
46 |
+
|
47 |
+
|
48 |
+
def calculate_duration(timestamp: str) -> float:
|
49 |
+
"""
|
50 |
+
计算时间戳范围的持续时间(秒)
|
51 |
+
|
52 |
+
Args:
|
53 |
+
timestamp: 格式为 'HH:MM:SS-HH:MM:SS' 或 'HH:MM:SS,sss-HH:MM:SS,sss' 的时间戳
|
54 |
+
|
55 |
+
Returns:
|
56 |
+
持续时间(秒)
|
57 |
+
"""
|
58 |
+
try:
|
59 |
+
start_time, end_time = timestamp.split('-')
|
60 |
+
|
61 |
+
# 处理毫秒部分
|
62 |
+
if ',' in start_time:
|
63 |
+
start_parts = start_time.split(',')
|
64 |
+
start_time_parts = start_parts[0].split(':')
|
65 |
+
start_ms = float('0.' + start_parts[1]) if len(start_parts) > 1 else 0
|
66 |
+
start_h, start_m, start_s = map(int, start_time_parts)
|
67 |
+
else:
|
68 |
+
start_h, start_m, start_s = map(int, start_time.split(':'))
|
69 |
+
start_ms = 0
|
70 |
+
|
71 |
+
if ',' in end_time:
|
72 |
+
end_parts = end_time.split(',')
|
73 |
+
end_time_parts = end_parts[0].split(':')
|
74 |
+
end_ms = float('0.' + end_parts[1]) if len(end_parts) > 1 else 0
|
75 |
+
end_h, end_m, end_s = map(int, end_time_parts)
|
76 |
+
else:
|
77 |
+
end_h, end_m, end_s = map(int, end_time.split(':'))
|
78 |
+
end_ms = 0
|
79 |
+
|
80 |
+
# 转换为秒
|
81 |
+
start_seconds = start_h * 3600 + start_m * 60 + start_s + start_ms
|
82 |
+
end_seconds = end_h * 3600 + end_m * 60 + end_s + end_ms
|
83 |
+
|
84 |
+
# 计算时间差(秒)
|
85 |
+
return round(end_seconds - start_seconds, 2)
|
86 |
+
except (ValueError, AttributeError):
|
87 |
+
return 0.0
|
88 |
+
|
89 |
+
|
90 |
+
def update_script_timestamps(
|
91 |
+
script_list: List[Dict[str, Any]],
|
92 |
+
video_result: Dict[Union[str, int], str],
|
93 |
+
audio_result: Dict[Union[str, int], str] = None,
|
94 |
+
subtitle_result: Dict[Union[str, int], str] = None,
|
95 |
+
calculate_edited_timerange: bool = True
|
96 |
+
) -> List[Dict[str, Any]]:
|
97 |
+
"""
|
98 |
+
根据 video_result 中的视频文件更新 script_list 中的时间戳,添加持续时间,
|
99 |
+
并根据 audio_result 添加音频路径,根据 subtitle_result 添加字幕路径
|
100 |
+
|
101 |
+
Args:
|
102 |
+
script_list: 原始脚本列表
|
103 |
+
video_result: 视频结果字典,键为原时间戳或_id,值为视频文件路径
|
104 |
+
audio_result: 音频结果字典,键为原时间戳或_id,值为音频文件路径
|
105 |
+
subtitle_result: 字幕结果字典,键为原时间戳或_id,值为字幕文件路径
|
106 |
+
calculate_edited_timerange: 是否计算并添加成品视频中的时间范围
|
107 |
+
|
108 |
+
Returns:
|
109 |
+
更新后的脚本列表
|
110 |
+
"""
|
111 |
+
# 创建副本,避免修改原始数据
|
112 |
+
updated_script = []
|
113 |
+
|
114 |
+
# 建立ID和时间戳到视频路径和新时间戳的映射
|
115 |
+
id_timestamp_mapping = {}
|
116 |
+
for key, video_path in video_result.items():
|
117 |
+
new_timestamp = extract_timestamp_from_video_path(video_path)
|
118 |
+
if new_timestamp:
|
119 |
+
id_timestamp_mapping[key] = {
|
120 |
+
'new_timestamp': new_timestamp,
|
121 |
+
'video_path': video_path
|
122 |
+
}
|
123 |
+
|
124 |
+
# 计算累积时长,用于生成成品视频中的时间范围
|
125 |
+
accumulated_duration = 0.0
|
126 |
+
|
127 |
+
# 更新脚本中的时间戳
|
128 |
+
for item in script_list:
|
129 |
+
item_copy = item.copy()
|
130 |
+
item_id = item_copy.get('_id')
|
131 |
+
orig_timestamp = item_copy.get('timestamp', '')
|
132 |
+
|
133 |
+
# 初始化音频和字幕路径为空字符串
|
134 |
+
item_copy['audio'] = ""
|
135 |
+
item_copy['subtitle'] = ""
|
136 |
+
item_copy['video'] = "" # 初始化视频路径为空字符串
|
137 |
+
|
138 |
+
# 如果��供了音频结果字典且ID存在于音频结果中,直接使用对应的音频路径
|
139 |
+
if audio_result:
|
140 |
+
if item_id and item_id in audio_result:
|
141 |
+
item_copy['audio'] = audio_result[item_id]
|
142 |
+
elif orig_timestamp in audio_result:
|
143 |
+
item_copy['audio'] = audio_result[orig_timestamp]
|
144 |
+
|
145 |
+
# 如果提供了字幕结果字典且ID存在于字幕结果中,直接使用对应的字幕路径
|
146 |
+
if subtitle_result:
|
147 |
+
if item_id and item_id in subtitle_result:
|
148 |
+
item_copy['subtitle'] = subtitle_result[item_id]
|
149 |
+
elif orig_timestamp in subtitle_result:
|
150 |
+
item_copy['subtitle'] = subtitle_result[orig_timestamp]
|
151 |
+
|
152 |
+
# 添加视频路径
|
153 |
+
if item_id and item_id in video_result:
|
154 |
+
item_copy['video'] = video_result[item_id]
|
155 |
+
elif orig_timestamp in video_result:
|
156 |
+
item_copy['video'] = video_result[orig_timestamp]
|
157 |
+
|
158 |
+
# 更新时间戳和计算持续时间
|
159 |
+
current_duration = 0.0
|
160 |
+
if item_id and item_id in id_timestamp_mapping:
|
161 |
+
# 根据ID找到对应的新时间戳
|
162 |
+
item_copy['sourceTimeRange'] = id_timestamp_mapping[item_id]['new_timestamp']
|
163 |
+
current_duration = calculate_duration(item_copy['sourceTimeRange'])
|
164 |
+
item_copy['duration'] = current_duration
|
165 |
+
elif orig_timestamp in id_timestamp_mapping:
|
166 |
+
# 根据原始时间戳找到对应的新时间戳
|
167 |
+
item_copy['sourceTimeRange'] = id_timestamp_mapping[orig_timestamp]['new_timestamp']
|
168 |
+
current_duration = calculate_duration(item_copy['sourceTimeRange'])
|
169 |
+
item_copy['duration'] = current_duration
|
170 |
+
elif orig_timestamp:
|
171 |
+
# 对于未更新的时间戳,也计算并添加持续时间
|
172 |
+
item_copy['sourceTimeRange'] = orig_timestamp
|
173 |
+
current_duration = calculate_duration(orig_timestamp)
|
174 |
+
item_copy['duration'] = current_duration
|
175 |
+
|
176 |
+
# 计算片段在成品视频中的时间范围
|
177 |
+
if calculate_edited_timerange and current_duration > 0:
|
178 |
+
start_time_seconds = accumulated_duration
|
179 |
+
end_time_seconds = accumulated_duration + current_duration
|
180 |
+
|
181 |
+
# 将秒数转换为 HH:MM:SS 格式
|
182 |
+
start_h = int(start_time_seconds // 3600)
|
183 |
+
start_m = int((start_time_seconds % 3600) // 60)
|
184 |
+
start_s = int(start_time_seconds % 60)
|
185 |
+
|
186 |
+
end_h = int(end_time_seconds // 3600)
|
187 |
+
end_m = int((end_time_seconds % 3600) // 60)
|
188 |
+
end_s = int(end_time_seconds % 60)
|
189 |
+
|
190 |
+
item_copy['editedTimeRange'] = f"{start_h:02d}:{start_m:02d}:{start_s:02d}-{end_h:02d}:{end_m:02d}:{end_s:02d}"
|
191 |
+
|
192 |
+
# 更新累积时长
|
193 |
+
accumulated_duration = end_time_seconds
|
194 |
+
|
195 |
+
updated_script.append(item_copy)
|
196 |
+
|
197 |
+
return updated_script
|
198 |
+
|
199 |
+
|
200 |
+
if __name__ == '__main__':
|
201 |
+
list_script = [
|
202 |
+
{
|
203 |
+
'picture': '【解说】好的,各位,欢迎回到我的频道!《庆余年 2》刚开播就给了我们一个王炸!范闲在北齐"死"了?这怎么可能!',
|
204 |
+
'timestamp': '00:00:00,001-00:01:15,001',
|
205 |
+
'narration': '好的各位,欢迎回到我的频道!《庆余年 2》刚开播就给了我们一个王炸!范闲在北齐"死"了?这怎么可能!上集片尾那个巨大的悬念,这一集就立刻揭晓了!范闲假死归来,他面临的第一个,也是最大的难关,就是如何面对他最敬爱的,同时也是最可怕的那个人——庆帝!',
|
206 |
+
'OST': 0,
|
207 |
+
'_id': 1
|
208 |
+
},
|
209 |
+
{
|
210 |
+
'picture': '【解说】上一集我们看到,范闲在北齐遭遇了惊天变故,生死不明!',
|
211 |
+
'timestamp': '00:01:15,001-00:04:40,001',
|
212 |
+
'narration': '但我们都知道,他绝不可能就这么轻易退场!第二集一开场,范闲就已经秘密回到了京都。他的生死传闻,可不像我们想象中那样只是小范围流传,而是…',
|
213 |
+
'OST': 0,
|
214 |
+
'_id': 2
|
215 |
+
},
|
216 |
+
{
|
217 |
+
'picture': '画面切到王启年小心翼翼地向范闲汇报。',
|
218 |
+
'timestamp': '00:04:41,001-00:04:58,001',
|
219 |
+
'narration': '我发现大人的死讯不光是在民间,在官场上也它传开了,所以呢,所以啊,可不是什么好事,将来您跟陛下怎么交代,这可是欺君之罪',
|
220 |
+
'OST': 1,
|
221 |
+
'_id': 3
|
222 |
+
},
|
223 |
+
{
|
224 |
+
'picture': '【解说】"欺君之罪"!在封建王朝,这可是抄家灭族的大罪!搁一般人,肯定脚底抹油溜之大吉了。',
|
225 |
+
'timestamp': '00:04:58,001-00:05:45,001',
|
226 |
+
'narration': '"欺君之罪"!在封建王朝,这可是抄家灭族的大罪!搁一般人,肯定脚底抹油溜之大吉了。但范闲是谁啊?他偏要反其道而行之!他竟然决定,直接去见庆帝!冒着天大的��险,用"假死"这个事实去赌庆帝的态度!',
|
227 |
+
'OST': 0,
|
228 |
+
'_id': 4
|
229 |
+
},
|
230 |
+
{
|
231 |
+
'picture': '【解说】但想见庆帝,哪有那么容易?范闲艺高人胆大,竟然选择了最激进的方式——闯宫!',
|
232 |
+
'timestamp': '00:05:45,001-00:06:00,001',
|
233 |
+
'narration': '但想见庆帝,哪有那么容易?范闲艺高人胆大,竟然选择了最激进的方式——闯宫!',
|
234 |
+
'OST': 0,
|
235 |
+
'_id': 5
|
236 |
+
},
|
237 |
+
{
|
238 |
+
'picture': '画面切换到范闲蒙面闯入皇宫,被侍卫包围的场景。',
|
239 |
+
'timestamp': '00:06:00,001-00:06:03,001',
|
240 |
+
'narration': '抓刺客',
|
241 |
+
'OST': 1,
|
242 |
+
'_id': 6
|
243 |
+
}]
|
244 |
+
video_res = {
|
245 |
+
1: '/Users/apple/Desktop/home/NarratoAI/storage/temp/clip_video/fc3db5844d1ba7d7d838be52c0dac1bd/[email protected]',
|
246 |
+
2: '/Users/apple/Desktop/home/NarratoAI/storage/temp/clip_video/fc3db5844d1ba7d7d838be52c0dac1bd/[email protected]',
|
247 |
+
4: '/Users/apple/Desktop/home/NarratoAI/storage/temp/clip_video/fc3db5844d1ba7d7d838be52c0dac1bd/[email protected]',
|
248 |
+
5: '/Users/apple/Desktop/home/NarratoAI/storage/temp/clip_video/fc3db5844d1ba7d7d838be52c0dac1bd/[email protected]'}
|
249 |
+
audio_res = {
|
250 |
+
1: '/Users/apple/Desktop/home/NarratoAI/storage/tasks/qyn2-2-demo/audio_00_00_00-00_01_15.mp3',
|
251 |
+
2: '/Users/apple/Desktop/home/NarratoAI/storage/tasks/qyn2-2-demo/audio_00_01_15-00_04_40.mp3',
|
252 |
+
4: '/Users/apple/Desktop/home/NarratoAI/storage/tasks/qyn2-2-demo/audio_00_04_58-00_05_45.mp3',
|
253 |
+
5: '/Users/apple/Desktop/home/NarratoAI/storage/tasks/qyn2-2-demo/audio_00_05_45-00_06_00.mp3'}
|
254 |
+
sub_res = {
|
255 |
+
1: '/Users/apple/Desktop/home/NarratoAI/storage/tasks/qyn2-2-demo/subtitle_00_00_00-00_01_15.srt',
|
256 |
+
2: '/Users/apple/Desktop/home/NarratoAI/storage/tasks/qyn2-2-demo/subtitle_00_01_15-00_04_40.srt',
|
257 |
+
4: '/Users/apple/Desktop/home/NarratoAI/storage/tasks/qyn2-2-demo/subtitle_00_04_58-00_05_45.srt',
|
258 |
+
5: '/Users/apple/Desktop/home/NarratoAI/storage/tasks/qyn2-2-demo/subtitle_00_05_45-00_06_00.srt'}
|
259 |
+
|
260 |
+
# 更新并打印结果
|
261 |
+
updated_list_script = update_script_timestamps(list_script, video_res, audio_res, sub_res)
|
262 |
+
for item in updated_list_script:
|
263 |
+
print(
|
264 |
+
f"ID: {item['_id']} | Picture: {item['picture'][:20]}... | Timestamp: {item['timestamp']} | " +
|
265 |
+
f"SourceTimeRange: {item['sourceTimeRange']} | EditedTimeRange: {item.get('editedTimeRange', '')} | " +
|
266 |
+
f"Duration: {item['duration']} 秒 | Audio: {item['audio']} | Video: {item['video']} | Subtitle: {item['subtitle']}")
|
app/services/video.py
ADDED
@@ -0,0 +1,365 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import traceback
|
2 |
+
|
3 |
+
# import pysrt
|
4 |
+
from typing import Optional
|
5 |
+
from typing import List
|
6 |
+
from loguru import logger
|
7 |
+
from moviepy import *
|
8 |
+
from PIL import ImageFont
|
9 |
+
from contextlib import contextmanager
|
10 |
+
from moviepy import (
|
11 |
+
VideoFileClip,
|
12 |
+
AudioFileClip,
|
13 |
+
TextClip,
|
14 |
+
CompositeVideoClip,
|
15 |
+
CompositeAudioClip
|
16 |
+
)
|
17 |
+
|
18 |
+
|
19 |
+
from app.models.schema import VideoAspect, SubtitlePosition
|
20 |
+
|
21 |
+
|
22 |
+
def wrap_text(text, max_width, font, fontsize=60):
|
23 |
+
"""
|
24 |
+
文本自动换行处理
|
25 |
+
Args:
|
26 |
+
text: 待处理的文本
|
27 |
+
max_width: 最大宽度
|
28 |
+
font: 字体文件路径
|
29 |
+
fontsize: 字体大小
|
30 |
+
|
31 |
+
Returns:
|
32 |
+
tuple: (换行后的文本, 文本高度)
|
33 |
+
"""
|
34 |
+
# 创建字体对象
|
35 |
+
font = ImageFont.truetype(font, fontsize)
|
36 |
+
|
37 |
+
def get_text_size(inner_text):
|
38 |
+
inner_text = inner_text.strip()
|
39 |
+
left, top, right, bottom = font.getbbox(inner_text)
|
40 |
+
return right - left, bottom - top
|
41 |
+
|
42 |
+
width, height = get_text_size(text)
|
43 |
+
if width <= max_width:
|
44 |
+
return text, height
|
45 |
+
|
46 |
+
logger.debug(f"换行文本, 最大宽度: {max_width}, 文本宽度: {width}, 文本: {text}")
|
47 |
+
|
48 |
+
processed = True
|
49 |
+
|
50 |
+
_wrapped_lines_ = []
|
51 |
+
words = text.split(" ")
|
52 |
+
_txt_ = ""
|
53 |
+
for word in words:
|
54 |
+
_before = _txt_
|
55 |
+
_txt_ += f"{word} "
|
56 |
+
_width, _height = get_text_size(_txt_)
|
57 |
+
if _width <= max_width:
|
58 |
+
continue
|
59 |
+
else:
|
60 |
+
if _txt_.strip() == word.strip():
|
61 |
+
processed = False
|
62 |
+
break
|
63 |
+
_wrapped_lines_.append(_before)
|
64 |
+
_txt_ = f"{word} "
|
65 |
+
_wrapped_lines_.append(_txt_)
|
66 |
+
if processed:
|
67 |
+
_wrapped_lines_ = [line.strip() for line in _wrapped_lines_]
|
68 |
+
result = "\n".join(_wrapped_lines_).strip()
|
69 |
+
height = len(_wrapped_lines_) * height
|
70 |
+
# logger.warning(f"wrapped text: {result}")
|
71 |
+
return result, height
|
72 |
+
|
73 |
+
_wrapped_lines_ = []
|
74 |
+
chars = list(text)
|
75 |
+
_txt_ = ""
|
76 |
+
for word in chars:
|
77 |
+
_txt_ += word
|
78 |
+
_width, _height = get_text_size(_txt_)
|
79 |
+
if _width <= max_width:
|
80 |
+
continue
|
81 |
+
else:
|
82 |
+
_wrapped_lines_.append(_txt_)
|
83 |
+
_txt_ = ""
|
84 |
+
_wrapped_lines_.append(_txt_)
|
85 |
+
result = "\n".join(_wrapped_lines_).strip()
|
86 |
+
height = len(_wrapped_lines_) * height
|
87 |
+
logger.debug(f"换行文本: {result}")
|
88 |
+
return result, height
|
89 |
+
|
90 |
+
|
91 |
+
@contextmanager
|
92 |
+
def manage_clip(clip):
|
93 |
+
"""
|
94 |
+
视频片段资源管理器
|
95 |
+
Args:
|
96 |
+
clip: 视频片段对象
|
97 |
+
|
98 |
+
Yields:
|
99 |
+
VideoFileClip: 视频片段对象
|
100 |
+
"""
|
101 |
+
try:
|
102 |
+
yield clip
|
103 |
+
finally:
|
104 |
+
clip.close()
|
105 |
+
del clip
|
106 |
+
|
107 |
+
|
108 |
+
def resize_video_with_padding(clip, target_width: int, target_height: int):
|
109 |
+
"""
|
110 |
+
调整视频尺寸并添加黑边
|
111 |
+
Args:
|
112 |
+
clip: 视频片段
|
113 |
+
target_width: 目标宽度
|
114 |
+
target_height: 目标高度
|
115 |
+
|
116 |
+
Returns:
|
117 |
+
CompositeVideoClip: 调整尺寸后的视频
|
118 |
+
"""
|
119 |
+
clip_ratio = clip.w / clip.h
|
120 |
+
target_ratio = target_width / target_height
|
121 |
+
|
122 |
+
if clip_ratio == target_ratio:
|
123 |
+
return clip.resize((target_width, target_height))
|
124 |
+
|
125 |
+
if clip_ratio > target_ratio:
|
126 |
+
scale_factor = target_width / clip.w
|
127 |
+
else:
|
128 |
+
scale_factor = target_height / clip.h
|
129 |
+
|
130 |
+
new_width = int(clip.w * scale_factor)
|
131 |
+
new_height = int(clip.h * scale_factor)
|
132 |
+
clip_resized = clip.resize(newsize=(new_width, new_height))
|
133 |
+
|
134 |
+
background = ColorClip(
|
135 |
+
size=(target_width, target_height),
|
136 |
+
color=(0, 0, 0)
|
137 |
+
).set_duration(clip.duration)
|
138 |
+
|
139 |
+
return CompositeVideoClip([
|
140 |
+
background,
|
141 |
+
clip_resized.set_position("center")
|
142 |
+
])
|
143 |
+
|
144 |
+
|
145 |
+
def loop_audio_clip(audio_clip: AudioFileClip, target_duration: float) -> AudioFileClip:
|
146 |
+
"""
|
147 |
+
循环音频片段直到达到目标时长
|
148 |
+
|
149 |
+
参数:
|
150 |
+
audio_clip: 原始音频片段
|
151 |
+
target_duration: 目标时长(秒)
|
152 |
+
返回:
|
153 |
+
循环后的音频片段
|
154 |
+
"""
|
155 |
+
# 计算需要循环的次数
|
156 |
+
loops_needed = int(target_duration / audio_clip.duration) + 1
|
157 |
+
|
158 |
+
# 创建足够长的音频
|
159 |
+
extended_audio = audio_clip
|
160 |
+
for _ in range(loops_needed - 1):
|
161 |
+
extended_audio = CompositeAudioClip([
|
162 |
+
extended_audio,
|
163 |
+
audio_clip.set_start(extended_audio.duration)
|
164 |
+
])
|
165 |
+
|
166 |
+
# 裁剪到目标时长
|
167 |
+
return extended_audio.subclip(0, target_duration)
|
168 |
+
|
169 |
+
|
170 |
+
def calculate_subtitle_position(position, video_height: int, text_height: int = 0) -> tuple:
|
171 |
+
"""
|
172 |
+
计算字幕在视频中的具体位置
|
173 |
+
|
174 |
+
Args:
|
175 |
+
position: 位置配置,可以是 SubtitlePosition 枚举值或表示距顶部百分比的浮点数
|
176 |
+
video_height: 视频高度
|
177 |
+
text_height: 字幕文本高度
|
178 |
+
|
179 |
+
Returns:
|
180 |
+
tuple: (x, y) 坐标
|
181 |
+
"""
|
182 |
+
margin = 50 # 字幕距离边缘的边距
|
183 |
+
|
184 |
+
if isinstance(position, (int, float)):
|
185 |
+
# 百分比位置
|
186 |
+
return ('center', int(video_height * position))
|
187 |
+
|
188 |
+
# 预设位置
|
189 |
+
if position == SubtitlePosition.TOP:
|
190 |
+
return ('center', margin)
|
191 |
+
elif position == SubtitlePosition.CENTER:
|
192 |
+
return ('center', video_height // 2)
|
193 |
+
elif position == SubtitlePosition.BOTTOM:
|
194 |
+
return ('center', video_height - margin - text_height)
|
195 |
+
|
196 |
+
# 默认底部
|
197 |
+
return ('center', video_height - margin - text_height)
|
198 |
+
|
199 |
+
|
200 |
+
def generate_video_v3(
|
201 |
+
video_path: str,
|
202 |
+
subtitle_style: dict,
|
203 |
+
volume_config: dict,
|
204 |
+
subtitle_path: Optional[str] = None,
|
205 |
+
bgm_path: Optional[str] = None,
|
206 |
+
narration_path: Optional[str] = None,
|
207 |
+
output_path: str = "output.mp4",
|
208 |
+
font_path: Optional[str] = None
|
209 |
+
) -> None:
|
210 |
+
"""
|
211 |
+
合并视频素材,包括视频、字幕、BGM和解说音频
|
212 |
+
|
213 |
+
参数:
|
214 |
+
video_path: 原视频文件路径
|
215 |
+
subtitle_path: SRT字幕文件路径(可选)
|
216 |
+
bgm_path: 背景音乐文件路径(可选)
|
217 |
+
narration_path: 解说音频文件路径(可选)
|
218 |
+
output_path: 输出文件路径
|
219 |
+
volume_config: 音量配置字典,可包含以下键:
|
220 |
+
- original: 原声音量(0-1),默认1.0
|
221 |
+
- bgm: BGM音量(0-1),默认0.3
|
222 |
+
- narration: 解说音量(0-1),默认1.0
|
223 |
+
subtitle_style: 字幕样式配置字典,可包含以下键:
|
224 |
+
- font: 字体名称
|
225 |
+
- fontsize: 字体大小
|
226 |
+
- color: 字体颜色
|
227 |
+
- stroke_color: 描边颜色
|
228 |
+
- stroke_width: 描边宽度
|
229 |
+
- bg_color: 背景色
|
230 |
+
- position: 位置支持 SubtitlePosition 枚举值或 0-1 之间的浮点数(表示距顶部的百分比)
|
231 |
+
- method: 文字渲染方法
|
232 |
+
font_path: 字体文件路径(.ttf/.otf 等格式)
|
233 |
+
"""
|
234 |
+
# 检查视频文件是否存在
|
235 |
+
if not os.path.exists(video_path):
|
236 |
+
raise FileNotFoundError(f"视频文件不存在: {video_path}")
|
237 |
+
|
238 |
+
# 加载视频
|
239 |
+
video = VideoFileClip(video_path)
|
240 |
+
subtitle_clips = []
|
241 |
+
|
242 |
+
# 处理字幕(如果提供)
|
243 |
+
if subtitle_path:
|
244 |
+
if os.path.exists(subtitle_path):
|
245 |
+
# 检查字体文件
|
246 |
+
if font_path and not os.path.exists(font_path):
|
247 |
+
logger.warning(f"警告:字体文件不存在: {font_path}")
|
248 |
+
|
249 |
+
try:
|
250 |
+
subs = pysrt.open(subtitle_path)
|
251 |
+
logger.info(f"读取到 {len(subs)} 条字幕")
|
252 |
+
|
253 |
+
for index, sub in enumerate(subs):
|
254 |
+
start_time = sub.start.ordinal / 1000
|
255 |
+
end_time = sub.end.ordinal / 1000
|
256 |
+
|
257 |
+
try:
|
258 |
+
# 检查字幕文本是否为空
|
259 |
+
if not sub.text or sub.text.strip() == '':
|
260 |
+
logger.info(f"警告:第 {index + 1} 条字幕内容为空,已跳过")
|
261 |
+
continue
|
262 |
+
|
263 |
+
# 处理字幕文本:确保是字符串,并处理可能的列表情况
|
264 |
+
if isinstance(sub.text, (list, tuple)):
|
265 |
+
subtitle_text = ' '.join(str(item) for item in sub.text if item is not None)
|
266 |
+
else:
|
267 |
+
subtitle_text = str(sub.text)
|
268 |
+
|
269 |
+
subtitle_text = subtitle_text.strip()
|
270 |
+
|
271 |
+
if not subtitle_text:
|
272 |
+
logger.info(f"警告:第 {index + 1} 条字幕处理后为空,已跳过")
|
273 |
+
continue
|
274 |
+
|
275 |
+
# 创建临时 TextClip 来获取文本高度
|
276 |
+
temp_clip = TextClip(
|
277 |
+
subtitle_text,
|
278 |
+
font=font_path,
|
279 |
+
fontsize=subtitle_style['fontsize'],
|
280 |
+
color=subtitle_style['color']
|
281 |
+
)
|
282 |
+
text_height = temp_clip.h
|
283 |
+
temp_clip.close()
|
284 |
+
|
285 |
+
# 计算字幕位置
|
286 |
+
position = calculate_subtitle_position(
|
287 |
+
subtitle_style['position'],
|
288 |
+
video.h,
|
289 |
+
text_height
|
290 |
+
)
|
291 |
+
|
292 |
+
# 创建最终的 TextClip
|
293 |
+
text_clip = (TextClip(
|
294 |
+
subtitle_text,
|
295 |
+
font=font_path,
|
296 |
+
fontsize=subtitle_style['fontsize'],
|
297 |
+
color=subtitle_style['color']
|
298 |
+
)
|
299 |
+
.set_position(position)
|
300 |
+
.set_duration(end_time - start_time)
|
301 |
+
.set_start(start_time))
|
302 |
+
subtitle_clips.append(text_clip)
|
303 |
+
|
304 |
+
except Exception as e:
|
305 |
+
logger.error(f"警告:创建第 {index + 1} 条字幕时出错: {traceback.format_exc()}")
|
306 |
+
|
307 |
+
logger.info(f"成功创建 {len(subtitle_clips)} 条字幕剪辑")
|
308 |
+
except Exception as e:
|
309 |
+
logger.info(f"警告:处理字幕文件时出错: {str(e)}")
|
310 |
+
else:
|
311 |
+
logger.info(f"提示:字幕文件不存在: {subtitle_path}")
|
312 |
+
|
313 |
+
# 合并音频
|
314 |
+
audio_clips = []
|
315 |
+
|
316 |
+
# 添加原声(设置音量)
|
317 |
+
logger.debug(f"音量配置: {volume_config}")
|
318 |
+
if video.audio is not None:
|
319 |
+
original_audio = video.audio.volumex(volume_config['original'])
|
320 |
+
audio_clips.append(original_audio)
|
321 |
+
|
322 |
+
# 添加BGM(如果提供)
|
323 |
+
if bgm_path:
|
324 |
+
bgm = AudioFileClip(bgm_path)
|
325 |
+
if bgm.duration < video.duration:
|
326 |
+
bgm = loop_audio_clip(bgm, video.duration)
|
327 |
+
else:
|
328 |
+
bgm = bgm.subclip(0, video.duration)
|
329 |
+
bgm = bgm.volumex(volume_config['bgm'])
|
330 |
+
audio_clips.append(bgm)
|
331 |
+
|
332 |
+
# 添加解说音频(如果提供)
|
333 |
+
if narration_path:
|
334 |
+
narration = AudioFileClip(narration_path).volumex(volume_config['narration'])
|
335 |
+
audio_clips.append(narration)
|
336 |
+
|
337 |
+
# 合成最终视频(包含字幕)
|
338 |
+
if subtitle_clips:
|
339 |
+
final_video = CompositeVideoClip([video] + subtitle_clips, size=video.size)
|
340 |
+
else:
|
341 |
+
logger.info("警告:没有字幕被添加到视频中")
|
342 |
+
final_video = video
|
343 |
+
|
344 |
+
if audio_clips:
|
345 |
+
final_audio = CompositeAudioClip(audio_clips)
|
346 |
+
final_video = final_video.set_audio(final_audio)
|
347 |
+
|
348 |
+
# 导出视频
|
349 |
+
logger.info("开始导出视频...") # 调试信息
|
350 |
+
final_video.write_videofile(
|
351 |
+
output_path,
|
352 |
+
codec='libx264',
|
353 |
+
audio_codec='aac',
|
354 |
+
fps=video.fps
|
355 |
+
)
|
356 |
+
logger.info(f"视频已导出到: {output_path}") # 调试信息
|
357 |
+
|
358 |
+
# 清理资源
|
359 |
+
video.close()
|
360 |
+
for clip in subtitle_clips:
|
361 |
+
clip.close()
|
362 |
+
if bgm_path:
|
363 |
+
bgm.close()
|
364 |
+
if narration_path:
|
365 |
+
narration.close()
|
app/services/video_service.py
ADDED
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import os
|
2 |
+
from uuid import uuid4
|
3 |
+
from loguru import logger
|
4 |
+
from typing import Dict, List, Optional, Tuple
|
5 |
+
|
6 |
+
from app.services import material
|
7 |
+
|
8 |
+
|
9 |
+
class VideoService:
|
10 |
+
@staticmethod
|
11 |
+
async def crop_video(
|
12 |
+
video_path: str,
|
13 |
+
video_script: List[dict]
|
14 |
+
) -> Tuple[str, Dict[str, str]]:
|
15 |
+
"""
|
16 |
+
裁剪视频服务
|
17 |
+
|
18 |
+
Args:
|
19 |
+
video_path: 视频文件路径
|
20 |
+
video_script: 视频脚本列表
|
21 |
+
|
22 |
+
Returns:
|
23 |
+
Tuple[str, Dict[str, str]]: (task_id, 裁剪后的视频片段字典)
|
24 |
+
视频片段字典格式: {timestamp: video_path}
|
25 |
+
"""
|
26 |
+
try:
|
27 |
+
task_id = str(uuid4())
|
28 |
+
|
29 |
+
# 从脚本中提取时间戳列表
|
30 |
+
time_list = [scene['timestamp'] for scene in video_script]
|
31 |
+
|
32 |
+
# 调用裁剪服务
|
33 |
+
subclip_videos = material.clip_videos(
|
34 |
+
task_id=task_id,
|
35 |
+
timestamp_terms=time_list,
|
36 |
+
origin_video=video_path
|
37 |
+
)
|
38 |
+
|
39 |
+
if subclip_videos is None:
|
40 |
+
raise ValueError("裁剪视频失败")
|
41 |
+
|
42 |
+
# 更新脚本中的视频路径
|
43 |
+
for scene in video_script:
|
44 |
+
try:
|
45 |
+
scene['path'] = subclip_videos[scene['timestamp']]
|
46 |
+
except KeyError as err:
|
47 |
+
logger.error(f"更新视频路径失败: {err}")
|
48 |
+
|
49 |
+
logger.debug(f"裁剪视频成功,共生成 {len(time_list)} 个视频片段")
|
50 |
+
logger.debug(f"视频片段路径: {subclip_videos}")
|
51 |
+
|
52 |
+
return task_id, subclip_videos
|
53 |
+
|
54 |
+
except Exception as e:
|
55 |
+
logger.exception("裁剪视频失败")
|
56 |
+
raise
|
app/services/voice.py
ADDED
@@ -0,0 +1,1469 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import os
|
2 |
+
import re
|
3 |
+
import json
|
4 |
+
import traceback
|
5 |
+
import edge_tts
|
6 |
+
import asyncio
|
7 |
+
from loguru import logger
|
8 |
+
from typing import List, Union
|
9 |
+
from datetime import datetime
|
10 |
+
from xml.sax.saxutils import unescape
|
11 |
+
from edge_tts import submaker, SubMaker
|
12 |
+
from edge_tts.submaker import mktimestamp
|
13 |
+
from moviepy.video.tools import subtitles
|
14 |
+
import time
|
15 |
+
|
16 |
+
from app.config import config
|
17 |
+
from app.utils import utils
|
18 |
+
|
19 |
+
|
20 |
+
def get_all_azure_voices(filter_locals=None) -> list[str]:
|
21 |
+
if filter_locals is None:
|
22 |
+
filter_locals = ["zh-CN", "en-US", "zh-HK", "zh-TW", "vi-VN"]
|
23 |
+
voices_str = """
|
24 |
+
Name: af-ZA-AdriNeural
|
25 |
+
Gender: Female
|
26 |
+
|
27 |
+
Name: af-ZA-WillemNeural
|
28 |
+
Gender: Male
|
29 |
+
|
30 |
+
Name: am-ET-AmehaNeural
|
31 |
+
Gender: Male
|
32 |
+
|
33 |
+
Name: am-ET-MekdesNeural
|
34 |
+
Gender: Female
|
35 |
+
|
36 |
+
Name: ar-AE-FatimaNeural
|
37 |
+
Gender: Female
|
38 |
+
|
39 |
+
Name: ar-AE-HamdanNeural
|
40 |
+
Gender: Male
|
41 |
+
|
42 |
+
Name: ar-BH-AliNeural
|
43 |
+
Gender: Male
|
44 |
+
|
45 |
+
Name: ar-BH-LailaNeural
|
46 |
+
Gender: Female
|
47 |
+
|
48 |
+
Name: ar-DZ-AminaNeural
|
49 |
+
Gender: Female
|
50 |
+
|
51 |
+
Name: ar-DZ-IsmaelNeural
|
52 |
+
Gender: Male
|
53 |
+
|
54 |
+
Name: ar-EG-SalmaNeural
|
55 |
+
Gender: Female
|
56 |
+
|
57 |
+
Name: ar-EG-ShakirNeural
|
58 |
+
Gender: Male
|
59 |
+
|
60 |
+
Name: ar-IQ-BasselNeural
|
61 |
+
Gender: Male
|
62 |
+
|
63 |
+
Name: ar-IQ-RanaNeural
|
64 |
+
Gender: Female
|
65 |
+
|
66 |
+
Name: ar-JO-SanaNeural
|
67 |
+
Gender: Female
|
68 |
+
|
69 |
+
Name: ar-JO-TaimNeural
|
70 |
+
Gender: Male
|
71 |
+
|
72 |
+
Name: ar-KW-FahedNeural
|
73 |
+
Gender: Male
|
74 |
+
|
75 |
+
Name: ar-KW-NouraNeural
|
76 |
+
Gender: Female
|
77 |
+
|
78 |
+
Name: ar-LB-LaylaNeural
|
79 |
+
Gender: Female
|
80 |
+
|
81 |
+
Name: ar-LB-RamiNeural
|
82 |
+
Gender: Male
|
83 |
+
|
84 |
+
Name: ar-LY-ImanNeural
|
85 |
+
Gender: Female
|
86 |
+
|
87 |
+
Name: ar-LY-OmarNeural
|
88 |
+
Gender: Male
|
89 |
+
|
90 |
+
Name: ar-MA-JamalNeural
|
91 |
+
Gender: Male
|
92 |
+
|
93 |
+
Name: ar-MA-MounaNeural
|
94 |
+
Gender: Female
|
95 |
+
|
96 |
+
Name: ar-OM-AbdullahNeural
|
97 |
+
Gender: Male
|
98 |
+
|
99 |
+
Name: ar-OM-AyshaNeural
|
100 |
+
Gender: Female
|
101 |
+
|
102 |
+
Name: ar-QA-AmalNeural
|
103 |
+
Gender: Female
|
104 |
+
|
105 |
+
Name: ar-QA-MoazNeural
|
106 |
+
Gender: Male
|
107 |
+
|
108 |
+
Name: ar-SA-HamedNeural
|
109 |
+
Gender: Male
|
110 |
+
|
111 |
+
Name: ar-SA-ZariyahNeural
|
112 |
+
Gender: Female
|
113 |
+
|
114 |
+
Name: ar-SY-AmanyNeural
|
115 |
+
Gender: Female
|
116 |
+
|
117 |
+
Name: ar-SY-LaithNeural
|
118 |
+
Gender: Male
|
119 |
+
|
120 |
+
Name: ar-TN-HediNeural
|
121 |
+
Gender: Male
|
122 |
+
|
123 |
+
Name: ar-TN-ReemNeural
|
124 |
+
Gender: Female
|
125 |
+
|
126 |
+
Name: ar-YE-MaryamNeural
|
127 |
+
Gender: Female
|
128 |
+
|
129 |
+
Name: ar-YE-SalehNeural
|
130 |
+
Gender: Male
|
131 |
+
|
132 |
+
Name: az-AZ-BabekNeural
|
133 |
+
Gender: Male
|
134 |
+
|
135 |
+
Name: az-AZ-BanuNeural
|
136 |
+
Gender: Female
|
137 |
+
|
138 |
+
Name: bg-BG-BorislavNeural
|
139 |
+
Gender: Male
|
140 |
+
|
141 |
+
Name: bg-BG-KalinaNeural
|
142 |
+
Gender: Female
|
143 |
+
|
144 |
+
Name: bn-BD-NabanitaNeural
|
145 |
+
Gender: Female
|
146 |
+
|
147 |
+
Name: bn-BD-PradeepNeural
|
148 |
+
Gender: Male
|
149 |
+
|
150 |
+
Name: bn-IN-BashkarNeural
|
151 |
+
Gender: Male
|
152 |
+
|
153 |
+
Name: bn-IN-TanishaaNeural
|
154 |
+
Gender: Female
|
155 |
+
|
156 |
+
Name: bs-BA-GoranNeural
|
157 |
+
Gender: Male
|
158 |
+
|
159 |
+
Name: bs-BA-VesnaNeural
|
160 |
+
Gender: Female
|
161 |
+
|
162 |
+
Name: ca-ES-EnricNeural
|
163 |
+
Gender: Male
|
164 |
+
|
165 |
+
Name: ca-ES-JoanaNeural
|
166 |
+
Gender: Female
|
167 |
+
|
168 |
+
Name: cs-CZ-AntoninNeural
|
169 |
+
Gender: Male
|
170 |
+
|
171 |
+
Name: cs-CZ-VlastaNeural
|
172 |
+
Gender: Female
|
173 |
+
|
174 |
+
Name: cy-GB-AledNeural
|
175 |
+
Gender: Male
|
176 |
+
|
177 |
+
Name: cy-GB-NiaNeural
|
178 |
+
Gender: Female
|
179 |
+
|
180 |
+
Name: da-DK-ChristelNeural
|
181 |
+
Gender: Female
|
182 |
+
|
183 |
+
Name: da-DK-JeppeNeural
|
184 |
+
Gender: Male
|
185 |
+
|
186 |
+
Name: de-AT-IngridNeural
|
187 |
+
Gender: Female
|
188 |
+
|
189 |
+
Name: de-AT-JonasNeural
|
190 |
+
Gender: Male
|
191 |
+
|
192 |
+
Name: de-CH-JanNeural
|
193 |
+
Gender: Male
|
194 |
+
|
195 |
+
Name: de-CH-LeniNeural
|
196 |
+
Gender: Female
|
197 |
+
|
198 |
+
Name: de-DE-AmalaNeural
|
199 |
+
Gender: Female
|
200 |
+
|
201 |
+
Name: de-DE-ConradNeural
|
202 |
+
Gender: Male
|
203 |
+
|
204 |
+
Name: de-DE-FlorianMultilingualNeural
|
205 |
+
Gender: Male
|
206 |
+
|
207 |
+
Name: de-DE-KatjaNeural
|
208 |
+
Gender: Female
|
209 |
+
|
210 |
+
Name: de-DE-KillianNeural
|
211 |
+
Gender: Male
|
212 |
+
|
213 |
+
Name: de-DE-SeraphinaMultilingualNeural
|
214 |
+
Gender: Female
|
215 |
+
|
216 |
+
Name: el-GR-AthinaNeural
|
217 |
+
Gender: Female
|
218 |
+
|
219 |
+
Name: el-GR-NestorasNeural
|
220 |
+
Gender: Male
|
221 |
+
|
222 |
+
Name: en-AU-NatashaNeural
|
223 |
+
Gender: Female
|
224 |
+
|
225 |
+
Name: en-AU-WilliamNeural
|
226 |
+
Gender: Male
|
227 |
+
|
228 |
+
Name: en-CA-ClaraNeural
|
229 |
+
Gender: Female
|
230 |
+
|
231 |
+
Name: en-CA-LiamNeural
|
232 |
+
Gender: Male
|
233 |
+
|
234 |
+
Name: en-GB-LibbyNeural
|
235 |
+
Gender: Female
|
236 |
+
|
237 |
+
Name: en-GB-MaisieNeural
|
238 |
+
Gender: Female
|
239 |
+
|
240 |
+
Name: en-GB-RyanNeural
|
241 |
+
Gender: Male
|
242 |
+
|
243 |
+
Name: en-GB-SoniaNeural
|
244 |
+
Gender: Female
|
245 |
+
|
246 |
+
Name: en-GB-ThomasNeural
|
247 |
+
Gender: Male
|
248 |
+
|
249 |
+
Name: en-HK-SamNeural
|
250 |
+
Gender: Male
|
251 |
+
|
252 |
+
Name: en-HK-YanNeural
|
253 |
+
Gender: Female
|
254 |
+
|
255 |
+
Name: en-IE-ConnorNeural
|
256 |
+
Gender: Male
|
257 |
+
|
258 |
+
Name: en-IE-EmilyNeural
|
259 |
+
Gender: Female
|
260 |
+
|
261 |
+
Name: en-IN-NeerjaExpressiveNeural
|
262 |
+
Gender: Female
|
263 |
+
|
264 |
+
Name: en-IN-NeerjaNeural
|
265 |
+
Gender: Female
|
266 |
+
|
267 |
+
Name: en-IN-PrabhatNeural
|
268 |
+
Gender: Male
|
269 |
+
|
270 |
+
Name: en-KE-AsiliaNeural
|
271 |
+
Gender: Female
|
272 |
+
|
273 |
+
Name: en-KE-ChilembaNeural
|
274 |
+
Gender: Male
|
275 |
+
|
276 |
+
Name: en-NG-AbeoNeural
|
277 |
+
Gender: Male
|
278 |
+
|
279 |
+
Name: en-NG-EzinneNeural
|
280 |
+
Gender: Female
|
281 |
+
|
282 |
+
Name: en-NZ-MitchellNeural
|
283 |
+
Gender: Male
|
284 |
+
|
285 |
+
Name: en-NZ-MollyNeural
|
286 |
+
Gender: Female
|
287 |
+
|
288 |
+
Name: en-PH-JamesNeural
|
289 |
+
Gender: Male
|
290 |
+
|
291 |
+
Name: en-PH-RosaNeural
|
292 |
+
Gender: Female
|
293 |
+
|
294 |
+
Name: en-SG-LunaNeural
|
295 |
+
Gender: Female
|
296 |
+
|
297 |
+
Name: en-SG-WayneNeural
|
298 |
+
Gender: Male
|
299 |
+
|
300 |
+
Name: en-TZ-ElimuNeural
|
301 |
+
Gender: Male
|
302 |
+
|
303 |
+
Name: en-TZ-ImaniNeural
|
304 |
+
Gender: Female
|
305 |
+
|
306 |
+
Name: en-US-AnaNeural
|
307 |
+
Gender: Female
|
308 |
+
|
309 |
+
Name: en-US-AndrewNeural
|
310 |
+
Gender: Male
|
311 |
+
|
312 |
+
Name: en-US-AriaNeural
|
313 |
+
Gender: Female
|
314 |
+
|
315 |
+
Name: en-US-AvaNeural
|
316 |
+
Gender: Female
|
317 |
+
|
318 |
+
Name: en-US-BrianNeural
|
319 |
+
Gender: Male
|
320 |
+
|
321 |
+
Name: en-US-ChristopherNeural
|
322 |
+
Gender: Male
|
323 |
+
|
324 |
+
Name: en-US-EmmaNeural
|
325 |
+
Gender: Female
|
326 |
+
|
327 |
+
Name: en-US-EricNeural
|
328 |
+
Gender: Male
|
329 |
+
|
330 |
+
Name: en-US-GuyNeural
|
331 |
+
Gender: Male
|
332 |
+
|
333 |
+
Name: en-US-JennyNeural
|
334 |
+
Gender: Female
|
335 |
+
|
336 |
+
Name: en-US-MichelleNeural
|
337 |
+
Gender: Female
|
338 |
+
|
339 |
+
Name: en-US-RogerNeural
|
340 |
+
Gender: Male
|
341 |
+
|
342 |
+
Name: en-US-SteffanNeural
|
343 |
+
Gender: Male
|
344 |
+
|
345 |
+
Name: en-ZA-LeahNeural
|
346 |
+
Gender: Female
|
347 |
+
|
348 |
+
Name: en-ZA-LukeNeural
|
349 |
+
Gender: Male
|
350 |
+
|
351 |
+
Name: es-AR-ElenaNeural
|
352 |
+
Gender: Female
|
353 |
+
|
354 |
+
Name: es-AR-TomasNeural
|
355 |
+
Gender: Male
|
356 |
+
|
357 |
+
Name: es-BO-MarceloNeural
|
358 |
+
Gender: Male
|
359 |
+
|
360 |
+
Name: es-BO-SofiaNeural
|
361 |
+
Gender: Female
|
362 |
+
|
363 |
+
Name: es-CL-CatalinaNeural
|
364 |
+
Gender: Female
|
365 |
+
|
366 |
+
Name: es-CL-LorenzoNeural
|
367 |
+
Gender: Male
|
368 |
+
|
369 |
+
Name: es-CO-GonzaloNeural
|
370 |
+
Gender: Male
|
371 |
+
|
372 |
+
Name: es-CO-SalomeNeural
|
373 |
+
Gender: Female
|
374 |
+
|
375 |
+
Name: es-CR-JuanNeural
|
376 |
+
Gender: Male
|
377 |
+
|
378 |
+
Name: es-CR-MariaNeural
|
379 |
+
Gender: Female
|
380 |
+
|
381 |
+
Name: es-CU-BelkysNeural
|
382 |
+
Gender: Female
|
383 |
+
|
384 |
+
Name: es-CU-ManuelNeural
|
385 |
+
Gender: Male
|
386 |
+
|
387 |
+
Name: es-DO-EmilioNeural
|
388 |
+
Gender: Male
|
389 |
+
|
390 |
+
Name: es-DO-RamonaNeural
|
391 |
+
Gender: Female
|
392 |
+
|
393 |
+
Name: es-EC-AndreaNeural
|
394 |
+
Gender: Female
|
395 |
+
|
396 |
+
Name: es-EC-LuisNeural
|
397 |
+
Gender: Male
|
398 |
+
|
399 |
+
Name: es-ES-AlvaroNeural
|
400 |
+
Gender: Male
|
401 |
+
|
402 |
+
Name: es-ES-ElviraNeural
|
403 |
+
Gender: Female
|
404 |
+
|
405 |
+
Name: es-ES-XimenaNeural
|
406 |
+
Gender: Female
|
407 |
+
|
408 |
+
Name: es-GQ-JavierNeural
|
409 |
+
Gender: Male
|
410 |
+
|
411 |
+
Name: es-GQ-TeresaNeural
|
412 |
+
Gender: Female
|
413 |
+
|
414 |
+
Name: es-GT-AndresNeural
|
415 |
+
Gender: Male
|
416 |
+
|
417 |
+
Name: es-GT-MartaNeural
|
418 |
+
Gender: Female
|
419 |
+
|
420 |
+
Name: es-HN-CarlosNeural
|
421 |
+
Gender: Male
|
422 |
+
|
423 |
+
Name: es-HN-KarlaNeural
|
424 |
+
Gender: Female
|
425 |
+
|
426 |
+
Name: es-MX-DaliaNeural
|
427 |
+
Gender: Female
|
428 |
+
|
429 |
+
Name: es-MX-JorgeNeural
|
430 |
+
Gender: Male
|
431 |
+
|
432 |
+
Name: es-NI-FedericoNeural
|
433 |
+
Gender: Male
|
434 |
+
|
435 |
+
Name: es-NI-YolandaNeural
|
436 |
+
Gender: Female
|
437 |
+
|
438 |
+
Name: es-PA-MargaritaNeural
|
439 |
+
Gender: Female
|
440 |
+
|
441 |
+
Name: es-PA-RobertoNeural
|
442 |
+
Gender: Male
|
443 |
+
|
444 |
+
Name: es-PE-AlexNeural
|
445 |
+
Gender: Male
|
446 |
+
|
447 |
+
Name: es-PE-CamilaNeural
|
448 |
+
Gender: Female
|
449 |
+
|
450 |
+
Name: es-PR-KarinaNeural
|
451 |
+
Gender: Female
|
452 |
+
|
453 |
+
Name: es-PR-VictorNeural
|
454 |
+
Gender: Male
|
455 |
+
|
456 |
+
Name: es-PY-MarioNeural
|
457 |
+
Gender: Male
|
458 |
+
|
459 |
+
Name: es-PY-TaniaNeural
|
460 |
+
Gender: Female
|
461 |
+
|
462 |
+
Name: es-SV-LorenaNeural
|
463 |
+
Gender: Female
|
464 |
+
|
465 |
+
Name: es-SV-RodrigoNeural
|
466 |
+
Gender: Male
|
467 |
+
|
468 |
+
Name: es-US-AlonsoNeural
|
469 |
+
Gender: Male
|
470 |
+
|
471 |
+
Name: es-US-PalomaNeural
|
472 |
+
Gender: Female
|
473 |
+
|
474 |
+
Name: es-UY-MateoNeural
|
475 |
+
Gender: Male
|
476 |
+
|
477 |
+
Name: es-UY-ValentinaNeural
|
478 |
+
Gender: Female
|
479 |
+
|
480 |
+
Name: es-VE-PaolaNeural
|
481 |
+
Gender: Female
|
482 |
+
|
483 |
+
Name: es-VE-SebastianNeural
|
484 |
+
Gender: Male
|
485 |
+
|
486 |
+
Name: et-EE-AnuNeural
|
487 |
+
Gender: Female
|
488 |
+
|
489 |
+
Name: et-EE-KertNeural
|
490 |
+
Gender: Male
|
491 |
+
|
492 |
+
Name: fa-IR-DilaraNeural
|
493 |
+
Gender: Female
|
494 |
+
|
495 |
+
Name: fa-IR-FaridNeural
|
496 |
+
Gender: Male
|
497 |
+
|
498 |
+
Name: fi-FI-HarriNeural
|
499 |
+
Gender: Male
|
500 |
+
|
501 |
+
Name: fi-FI-NooraNeural
|
502 |
+
Gender: Female
|
503 |
+
|
504 |
+
Name: fil-PH-AngeloNeural
|
505 |
+
Gender: Male
|
506 |
+
|
507 |
+
Name: fil-PH-BlessicaNeural
|
508 |
+
Gender: Female
|
509 |
+
|
510 |
+
Name: fr-BE-CharlineNeural
|
511 |
+
Gender: Female
|
512 |
+
|
513 |
+
Name: fr-BE-GerardNeural
|
514 |
+
Gender: Male
|
515 |
+
|
516 |
+
Name: fr-CA-AntoineNeural
|
517 |
+
Gender: Male
|
518 |
+
|
519 |
+
Name: fr-CA-JeanNeural
|
520 |
+
Gender: Male
|
521 |
+
|
522 |
+
Name: fr-CA-SylvieNeural
|
523 |
+
Gender: Female
|
524 |
+
|
525 |
+
Name: fr-CA-ThierryNeural
|
526 |
+
Gender: Male
|
527 |
+
|
528 |
+
Name: fr-CH-ArianeNeural
|
529 |
+
Gender: Female
|
530 |
+
|
531 |
+
Name: fr-CH-FabriceNeural
|
532 |
+
Gender: Male
|
533 |
+
|
534 |
+
Name: fr-FR-DeniseNeural
|
535 |
+
Gender: Female
|
536 |
+
|
537 |
+
Name: fr-FR-EloiseNeural
|
538 |
+
Gender: Female
|
539 |
+
|
540 |
+
Name: fr-FR-HenriNeural
|
541 |
+
Gender: Male
|
542 |
+
|
543 |
+
Name: fr-FR-RemyMultilingualNeural
|
544 |
+
Gender: Male
|
545 |
+
|
546 |
+
Name: fr-FR-VivienneMultilingualNeural
|
547 |
+
Gender: Female
|
548 |
+
|
549 |
+
Name: ga-IE-ColmNeural
|
550 |
+
Gender: Male
|
551 |
+
|
552 |
+
Name: ga-IE-OrlaNeural
|
553 |
+
Gender: Female
|
554 |
+
|
555 |
+
Name: gl-ES-RoiNeural
|
556 |
+
Gender: Male
|
557 |
+
|
558 |
+
Name: gl-ES-SabelaNeural
|
559 |
+
Gender: Female
|
560 |
+
|
561 |
+
Name: gu-IN-DhwaniNeural
|
562 |
+
Gender: Female
|
563 |
+
|
564 |
+
Name: gu-IN-NiranjanNeural
|
565 |
+
Gender: Male
|
566 |
+
|
567 |
+
Name: he-IL-AvriNeural
|
568 |
+
Gender: Male
|
569 |
+
|
570 |
+
Name: he-IL-HilaNeural
|
571 |
+
Gender: Female
|
572 |
+
|
573 |
+
Name: hi-IN-MadhurNeural
|
574 |
+
Gender: Male
|
575 |
+
|
576 |
+
Name: hi-IN-SwaraNeural
|
577 |
+
Gender: Female
|
578 |
+
|
579 |
+
Name: hr-HR-GabrijelaNeural
|
580 |
+
Gender: Female
|
581 |
+
|
582 |
+
Name: hr-HR-SreckoNeural
|
583 |
+
Gender: Male
|
584 |
+
|
585 |
+
Name: hu-HU-NoemiNeural
|
586 |
+
Gender: Female
|
587 |
+
|
588 |
+
Name: hu-HU-TamasNeural
|
589 |
+
Gender: Male
|
590 |
+
|
591 |
+
Name: id-ID-ArdiNeural
|
592 |
+
Gender: Male
|
593 |
+
|
594 |
+
Name: id-ID-GadisNeural
|
595 |
+
Gender: Female
|
596 |
+
|
597 |
+
Name: is-IS-GudrunNeural
|
598 |
+
Gender: Female
|
599 |
+
|
600 |
+
Name: is-IS-GunnarNeural
|
601 |
+
Gender: Male
|
602 |
+
|
603 |
+
Name: it-IT-DiegoNeural
|
604 |
+
Gender: Male
|
605 |
+
|
606 |
+
Name: it-IT-ElsaNeural
|
607 |
+
Gender: Female
|
608 |
+
|
609 |
+
Name: it-IT-GiuseppeNeural
|
610 |
+
Gender: Male
|
611 |
+
|
612 |
+
Name: it-IT-IsabellaNeural
|
613 |
+
Gender: Female
|
614 |
+
|
615 |
+
Name: ja-JP-KeitaNeural
|
616 |
+
Gender: Male
|
617 |
+
|
618 |
+
Name: ja-JP-NanamiNeural
|
619 |
+
Gender: Female
|
620 |
+
|
621 |
+
Name: jv-ID-DimasNeural
|
622 |
+
Gender: Male
|
623 |
+
|
624 |
+
Name: jv-ID-SitiNeural
|
625 |
+
Gender: Female
|
626 |
+
|
627 |
+
Name: ka-GE-EkaNeural
|
628 |
+
Gender: Female
|
629 |
+
|
630 |
+
Name: ka-GE-GiorgiNeural
|
631 |
+
Gender: Male
|
632 |
+
|
633 |
+
Name: kk-KZ-AigulNeural
|
634 |
+
Gender: Female
|
635 |
+
|
636 |
+
Name: kk-KZ-DauletNeural
|
637 |
+
Gender: Male
|
638 |
+
|
639 |
+
Name: km-KH-PisethNeural
|
640 |
+
Gender: Male
|
641 |
+
|
642 |
+
Name: km-KH-SreymomNeural
|
643 |
+
Gender: Female
|
644 |
+
|
645 |
+
Name: kn-IN-GaganNeural
|
646 |
+
Gender: Male
|
647 |
+
|
648 |
+
Name: kn-IN-SapnaNeural
|
649 |
+
Gender: Female
|
650 |
+
|
651 |
+
Name: ko-KR-HyunsuNeural
|
652 |
+
Gender: Male
|
653 |
+
|
654 |
+
Name: ko-KR-InJoonNeural
|
655 |
+
Gender: Male
|
656 |
+
|
657 |
+
Name: ko-KR-SunHiNeural
|
658 |
+
Gender: Female
|
659 |
+
|
660 |
+
Name: lo-LA-ChanthavongNeural
|
661 |
+
Gender: Male
|
662 |
+
|
663 |
+
Name: lo-LA-KeomanyNeural
|
664 |
+
Gender: Female
|
665 |
+
|
666 |
+
Name: lt-LT-LeonasNeural
|
667 |
+
Gender: Male
|
668 |
+
|
669 |
+
Name: lt-LT-OnaNeural
|
670 |
+
Gender: Female
|
671 |
+
|
672 |
+
Name: lv-LV-EveritaNeural
|
673 |
+
Gender: Female
|
674 |
+
|
675 |
+
Name: lv-LV-NilsNeural
|
676 |
+
Gender: Male
|
677 |
+
|
678 |
+
Name: mk-MK-AleksandarNeural
|
679 |
+
Gender: Male
|
680 |
+
|
681 |
+
Name: mk-MK-MarijaNeural
|
682 |
+
Gender: Female
|
683 |
+
|
684 |
+
Name: ml-IN-MidhunNeural
|
685 |
+
Gender: Male
|
686 |
+
|
687 |
+
Name: ml-IN-SobhanaNeural
|
688 |
+
Gender: Female
|
689 |
+
|
690 |
+
Name: mn-MN-BataaNeural
|
691 |
+
Gender: Male
|
692 |
+
|
693 |
+
Name: mn-MN-YesuiNeural
|
694 |
+
Gender: Female
|
695 |
+
|
696 |
+
Name: mr-IN-AarohiNeural
|
697 |
+
Gender: Female
|
698 |
+
|
699 |
+
Name: mr-IN-ManoharNeural
|
700 |
+
Gender: Male
|
701 |
+
|
702 |
+
Name: ms-MY-OsmanNeural
|
703 |
+
Gender: Male
|
704 |
+
|
705 |
+
Name: ms-MY-YasminNeural
|
706 |
+
Gender: Female
|
707 |
+
|
708 |
+
Name: mt-MT-GraceNeural
|
709 |
+
Gender: Female
|
710 |
+
|
711 |
+
Name: mt-MT-JosephNeural
|
712 |
+
Gender: Male
|
713 |
+
|
714 |
+
Name: my-MM-NilarNeural
|
715 |
+
Gender: Female
|
716 |
+
|
717 |
+
Name: my-MM-ThihaNeural
|
718 |
+
Gender: Male
|
719 |
+
|
720 |
+
Name: nb-NO-FinnNeural
|
721 |
+
Gender: Male
|
722 |
+
|
723 |
+
Name: nb-NO-PernilleNeural
|
724 |
+
Gender: Female
|
725 |
+
|
726 |
+
Name: ne-NP-HemkalaNeural
|
727 |
+
Gender: Female
|
728 |
+
|
729 |
+
Name: ne-NP-SagarNeural
|
730 |
+
Gender: Male
|
731 |
+
|
732 |
+
Name: nl-BE-ArnaudNeural
|
733 |
+
Gender: Male
|
734 |
+
|
735 |
+
Name: nl-BE-DenaNeural
|
736 |
+
Gender: Female
|
737 |
+
|
738 |
+
Name: nl-NL-ColetteNeural
|
739 |
+
Gender: Female
|
740 |
+
|
741 |
+
Name: nl-NL-FennaNeural
|
742 |
+
Gender: Female
|
743 |
+
|
744 |
+
Name: nl-NL-MaartenNeural
|
745 |
+
Gender: Male
|
746 |
+
|
747 |
+
Name: pl-PL-MarekNeural
|
748 |
+
Gender: Male
|
749 |
+
|
750 |
+
Name: pl-PL-ZofiaNeural
|
751 |
+
Gender: Female
|
752 |
+
|
753 |
+
Name: ps-AF-GulNawazNeural
|
754 |
+
Gender: Male
|
755 |
+
|
756 |
+
Name: ps-AF-LatifaNeural
|
757 |
+
Gender: Female
|
758 |
+
|
759 |
+
Name: pt-BR-AntonioNeural
|
760 |
+
Gender: Male
|
761 |
+
|
762 |
+
Name: pt-BR-FranciscaNeural
|
763 |
+
Gender: Female
|
764 |
+
|
765 |
+
Name: pt-BR-ThalitaNeural
|
766 |
+
Gender: Female
|
767 |
+
|
768 |
+
Name: pt-PT-DuarteNeural
|
769 |
+
Gender: Male
|
770 |
+
|
771 |
+
Name: pt-PT-RaquelNeural
|
772 |
+
Gender: Female
|
773 |
+
|
774 |
+
Name: ro-RO-AlinaNeural
|
775 |
+
Gender: Female
|
776 |
+
|
777 |
+
Name: ro-RO-EmilNeural
|
778 |
+
Gender: Male
|
779 |
+
|
780 |
+
Name: ru-RU-DmitryNeural
|
781 |
+
Gender: Male
|
782 |
+
|
783 |
+
Name: ru-RU-SvetlanaNeural
|
784 |
+
Gender: Female
|
785 |
+
|
786 |
+
Name: si-LK-SameeraNeural
|
787 |
+
Gender: Male
|
788 |
+
|
789 |
+
Name: si-LK-ThiliniNeural
|
790 |
+
Gender: Female
|
791 |
+
|
792 |
+
Name: sk-SK-LukasNeural
|
793 |
+
Gender: Male
|
794 |
+
|
795 |
+
Name: sk-SK-ViktoriaNeural
|
796 |
+
Gender: Female
|
797 |
+
|
798 |
+
Name: sl-SI-PetraNeural
|
799 |
+
Gender: Female
|
800 |
+
|
801 |
+
Name: sl-SI-RokNeural
|
802 |
+
Gender: Male
|
803 |
+
|
804 |
+
Name: so-SO-MuuseNeural
|
805 |
+
Gender: Male
|
806 |
+
|
807 |
+
Name: so-SO-UbaxNeural
|
808 |
+
Gender: Female
|
809 |
+
|
810 |
+
Name: sq-AL-AnilaNeural
|
811 |
+
Gender: Female
|
812 |
+
|
813 |
+
Name: sq-AL-IlirNeural
|
814 |
+
Gender: Male
|
815 |
+
|
816 |
+
Name: sr-RS-NicholasNeural
|
817 |
+
Gender: Male
|
818 |
+
|
819 |
+
Name: sr-RS-SophieNeural
|
820 |
+
Gender: Female
|
821 |
+
|
822 |
+
Name: su-ID-JajangNeural
|
823 |
+
Gender: Male
|
824 |
+
|
825 |
+
Name: su-ID-TutiNeural
|
826 |
+
Gender: Female
|
827 |
+
|
828 |
+
Name: sv-SE-MattiasNeural
|
829 |
+
Gender: Male
|
830 |
+
|
831 |
+
Name: sv-SE-SofieNeural
|
832 |
+
Gender: Female
|
833 |
+
|
834 |
+
Name: sw-KE-RafikiNeural
|
835 |
+
Gender: Male
|
836 |
+
|
837 |
+
Name: sw-KE-ZuriNeural
|
838 |
+
Gender: Female
|
839 |
+
|
840 |
+
Name: sw-TZ-DaudiNeural
|
841 |
+
Gender: Male
|
842 |
+
|
843 |
+
Name: sw-TZ-RehemaNeural
|
844 |
+
Gender: Female
|
845 |
+
|
846 |
+
Name: ta-IN-PallaviNeural
|
847 |
+
Gender: Female
|
848 |
+
|
849 |
+
Name: ta-IN-ValluvarNeural
|
850 |
+
Gender: Male
|
851 |
+
|
852 |
+
Name: ta-LK-KumarNeural
|
853 |
+
Gender: Male
|
854 |
+
|
855 |
+
Name: ta-LK-SaranyaNeural
|
856 |
+
Gender: Female
|
857 |
+
|
858 |
+
Name: ta-MY-KaniNeural
|
859 |
+
Gender: Female
|
860 |
+
|
861 |
+
Name: ta-MY-SuryaNeural
|
862 |
+
Gender: Male
|
863 |
+
|
864 |
+
Name: ta-SG-AnbuNeural
|
865 |
+
Gender: Male
|
866 |
+
|
867 |
+
Name: ta-SG-VenbaNeural
|
868 |
+
Gender: Female
|
869 |
+
|
870 |
+
Name: te-IN-MohanNeural
|
871 |
+
Gender: Male
|
872 |
+
|
873 |
+
Name: te-IN-ShrutiNeural
|
874 |
+
Gender: Female
|
875 |
+
|
876 |
+
Name: th-TH-NiwatNeural
|
877 |
+
Gender: Male
|
878 |
+
|
879 |
+
Name: th-TH-PremwadeeNeural
|
880 |
+
Gender: Female
|
881 |
+
|
882 |
+
Name: tr-TR-AhmetNeural
|
883 |
+
Gender: Male
|
884 |
+
|
885 |
+
Name: tr-TR-EmelNeural
|
886 |
+
Gender: Female
|
887 |
+
|
888 |
+
Name: uk-UA-OstapNeural
|
889 |
+
Gender: Male
|
890 |
+
|
891 |
+
Name: uk-UA-PolinaNeural
|
892 |
+
Gender: Female
|
893 |
+
|
894 |
+
Name: ur-IN-GulNeural
|
895 |
+
Gender: Female
|
896 |
+
|
897 |
+
Name: ur-IN-SalmanNeural
|
898 |
+
Gender: Male
|
899 |
+
|
900 |
+
Name: ur-PK-AsadNeural
|
901 |
+
Gender: Male
|
902 |
+
|
903 |
+
Name: ur-PK-UzmaNeural
|
904 |
+
Gender: Female
|
905 |
+
|
906 |
+
Name: uz-UZ-MadinaNeural
|
907 |
+
Gender: Female
|
908 |
+
|
909 |
+
Name: uz-UZ-SardorNeural
|
910 |
+
Gender: Male
|
911 |
+
|
912 |
+
Name: vi-VN-HoaiMyNeural
|
913 |
+
Gender: Female
|
914 |
+
|
915 |
+
Name: vi-VN-NamMinhNeural
|
916 |
+
Gender: Male
|
917 |
+
|
918 |
+
Name: zh-CN-XiaoxiaoNeural
|
919 |
+
Gender: Female
|
920 |
+
|
921 |
+
Name: zh-CN-XiaoyiNeural
|
922 |
+
Gender: Female
|
923 |
+
|
924 |
+
Name: zh-CN-YunjianNeural
|
925 |
+
Gender: Male
|
926 |
+
|
927 |
+
Name: zh-CN-YunxiNeural
|
928 |
+
Gender: Male
|
929 |
+
|
930 |
+
Name: zh-CN-YunxiaNeural
|
931 |
+
Gender: Male
|
932 |
+
|
933 |
+
Name: zh-CN-YunyangNeural
|
934 |
+
Gender: Male
|
935 |
+
|
936 |
+
Name: zh-CN-liaoning-XiaobeiNeural
|
937 |
+
Gender: Female
|
938 |
+
|
939 |
+
Name: zh-CN-shaanxi-XiaoniNeural
|
940 |
+
Gender: Female
|
941 |
+
|
942 |
+
Name: zh-HK-HiuGaaiNeural
|
943 |
+
Gender: Female
|
944 |
+
|
945 |
+
Name: zh-HK-HiuMaanNeural
|
946 |
+
Gender: Female
|
947 |
+
|
948 |
+
Name: zh-HK-WanLungNeural
|
949 |
+
Gender: Male
|
950 |
+
|
951 |
+
Name: zh-TW-HsiaoChenNeural
|
952 |
+
Gender: Female
|
953 |
+
|
954 |
+
Name: zh-TW-HsiaoYuNeural
|
955 |
+
Gender: Female
|
956 |
+
|
957 |
+
Name: zh-TW-YunJheNeural
|
958 |
+
Gender: Male
|
959 |
+
|
960 |
+
Name: zu-ZA-ThandoNeural
|
961 |
+
Gender: Female
|
962 |
+
|
963 |
+
Name: zu-ZA-ThembaNeural
|
964 |
+
Gender: Male
|
965 |
+
|
966 |
+
|
967 |
+
Name: en-US-AvaMultilingualNeural-V2
|
968 |
+
Gender: Female
|
969 |
+
|
970 |
+
Name: en-US-AndrewMultilingualNeural-V2
|
971 |
+
Gender: Male
|
972 |
+
|
973 |
+
Name: en-US-EmmaMultilingualNeural-V2
|
974 |
+
Gender: Female
|
975 |
+
|
976 |
+
Name: en-US-BrianMultilingualNeural-V2
|
977 |
+
Gender: Male
|
978 |
+
|
979 |
+
Name: de-DE-FlorianMultilingualNeural-V2
|
980 |
+
Gender: Male
|
981 |
+
|
982 |
+
Name: de-DE-SeraphinaMultilingualNeural-V2
|
983 |
+
Gender: Female
|
984 |
+
|
985 |
+
Name: fr-FR-RemyMultilingualNeural-V2
|
986 |
+
Gender: Male
|
987 |
+
|
988 |
+
Name: fr-FR-VivienneMultilingualNeural-V2
|
989 |
+
Gender: Female
|
990 |
+
|
991 |
+
Name: zh-CN-XiaoxiaoMultilingualNeural-V2
|
992 |
+
Gender: Female
|
993 |
+
|
994 |
+
Name: zh-CN-YunxiNeural-V2
|
995 |
+
Gender: Male
|
996 |
+
""".strip()
|
997 |
+
voices = []
|
998 |
+
name = ""
|
999 |
+
for line in voices_str.split("\n"):
|
1000 |
+
line = line.strip()
|
1001 |
+
if not line:
|
1002 |
+
continue
|
1003 |
+
if line.startswith("Name: "):
|
1004 |
+
name = line[6:].strip()
|
1005 |
+
if line.startswith("Gender: "):
|
1006 |
+
gender = line[8:].strip()
|
1007 |
+
if name and gender:
|
1008 |
+
# voices.append({
|
1009 |
+
# "name": name,
|
1010 |
+
# "gender": gender,
|
1011 |
+
# })
|
1012 |
+
if filter_locals:
|
1013 |
+
for filter_local in filter_locals:
|
1014 |
+
if name.lower().startswith(filter_local.lower()):
|
1015 |
+
voices.append(f"{name}-{gender}")
|
1016 |
+
else:
|
1017 |
+
voices.append(f"{name}-{gender}")
|
1018 |
+
name = ""
|
1019 |
+
voices.sort()
|
1020 |
+
return voices
|
1021 |
+
|
1022 |
+
|
1023 |
+
def parse_voice_name(name: str):
|
1024 |
+
# zh-CN-XiaoyiNeural-Female
|
1025 |
+
# zh-CN-YunxiNeural-Male
|
1026 |
+
# zh-CN-XiaoxiaoMultilingualNeural-V2-Female
|
1027 |
+
name = name.replace("-Female", "").replace("-Male", "").strip()
|
1028 |
+
return name
|
1029 |
+
|
1030 |
+
|
1031 |
+
def is_azure_v2_voice(voice_name: str):
|
1032 |
+
voice_name = parse_voice_name(voice_name)
|
1033 |
+
if voice_name.endswith("-V2"):
|
1034 |
+
return voice_name.replace("-V2", "").strip()
|
1035 |
+
return ""
|
1036 |
+
|
1037 |
+
|
1038 |
+
def tts(
|
1039 |
+
text: str, voice_name: str, voice_rate: float, voice_pitch: float, voice_file: str
|
1040 |
+
) -> Union[SubMaker, None]:
|
1041 |
+
if is_azure_v2_voice(voice_name):
|
1042 |
+
return azure_tts_v2(text, voice_name, voice_file)
|
1043 |
+
return azure_tts_v1(text, voice_name, voice_rate, voice_pitch, voice_file)
|
1044 |
+
|
1045 |
+
|
1046 |
+
def convert_rate_to_percent(rate: float) -> str:
|
1047 |
+
if rate == 1.0:
|
1048 |
+
return "+0%"
|
1049 |
+
percent = round((rate - 1.0) * 100)
|
1050 |
+
if percent > 0:
|
1051 |
+
return f"+{percent}%"
|
1052 |
+
else:
|
1053 |
+
return f"{percent}%"
|
1054 |
+
|
1055 |
+
|
1056 |
+
def convert_pitch_to_percent(rate: float) -> str:
|
1057 |
+
if rate == 1.0:
|
1058 |
+
return "+0Hz"
|
1059 |
+
percent = round((rate - 1.0) * 100)
|
1060 |
+
if percent > 0:
|
1061 |
+
return f"+{percent}Hz"
|
1062 |
+
else:
|
1063 |
+
return f"{percent}Hz"
|
1064 |
+
|
1065 |
+
|
1066 |
+
def azure_tts_v1(
|
1067 |
+
text: str, voice_name: str, voice_rate: float, voice_pitch: float, voice_file: str
|
1068 |
+
) -> Union[SubMaker, None]:
|
1069 |
+
voice_name = parse_voice_name(voice_name)
|
1070 |
+
text = text.strip()
|
1071 |
+
rate_str = convert_rate_to_percent(voice_rate)
|
1072 |
+
pitch_str = convert_pitch_to_percent(voice_pitch)
|
1073 |
+
for i in range(3):
|
1074 |
+
try:
|
1075 |
+
logger.info(f"第 {i+1} 次使用 edge_tts 生成音频")
|
1076 |
+
|
1077 |
+
async def _do() -> tuple[SubMaker, bytes]:
|
1078 |
+
communicate = edge_tts.Communicate(text, voice_name, rate=rate_str, pitch=pitch_str, proxy=config.proxy.get("http"))
|
1079 |
+
sub_maker = edge_tts.SubMaker()
|
1080 |
+
audio_data = bytes() # 用于存储音频数据
|
1081 |
+
|
1082 |
+
async for chunk in communicate.stream():
|
1083 |
+
if chunk["type"] == "audio":
|
1084 |
+
audio_data += chunk["data"]
|
1085 |
+
elif chunk["type"] == "WordBoundary":
|
1086 |
+
sub_maker.create_sub(
|
1087 |
+
(chunk["offset"], chunk["duration"]), chunk["text"]
|
1088 |
+
)
|
1089 |
+
return sub_maker, audio_data
|
1090 |
+
|
1091 |
+
# 获取音频数据和字幕信息
|
1092 |
+
sub_maker, audio_data = asyncio.run(_do())
|
1093 |
+
|
1094 |
+
# 验证数据是否有效
|
1095 |
+
if not sub_maker or not sub_maker.subs or not audio_data:
|
1096 |
+
logger.warning(f"failed, invalid data generated")
|
1097 |
+
if i < 2:
|
1098 |
+
time.sleep(1)
|
1099 |
+
continue
|
1100 |
+
|
1101 |
+
# 数据有效,写入文件
|
1102 |
+
with open(voice_file, "wb") as file:
|
1103 |
+
file.write(audio_data)
|
1104 |
+
return sub_maker
|
1105 |
+
except Exception as e:
|
1106 |
+
logger.error(f"生成音频文件时出错: {str(e)}")
|
1107 |
+
if i < 2:
|
1108 |
+
time.sleep(1)
|
1109 |
+
return None
|
1110 |
+
|
1111 |
+
|
1112 |
+
def azure_tts_v2(text: str, voice_name: str, voice_file: str) -> Union[SubMaker, None]:
|
1113 |
+
voice_name = is_azure_v2_voice(voice_name)
|
1114 |
+
if not voice_name:
|
1115 |
+
logger.error(f"invalid voice name: {voice_name}")
|
1116 |
+
raise ValueError(f"invalid voice name: {voice_name}")
|
1117 |
+
text = text.strip()
|
1118 |
+
|
1119 |
+
def _format_duration_to_offset(duration) -> int:
|
1120 |
+
if isinstance(duration, str):
|
1121 |
+
time_obj = datetime.strptime(duration, "%H:%M:%S.%f")
|
1122 |
+
milliseconds = (
|
1123 |
+
(time_obj.hour * 3600000)
|
1124 |
+
+ (time_obj.minute * 60000)
|
1125 |
+
+ (time_obj.second * 1000)
|
1126 |
+
+ (time_obj.microsecond // 1000)
|
1127 |
+
)
|
1128 |
+
return milliseconds * 10000
|
1129 |
+
|
1130 |
+
if isinstance(duration, int):
|
1131 |
+
return duration
|
1132 |
+
|
1133 |
+
return 0
|
1134 |
+
|
1135 |
+
for i in range(3):
|
1136 |
+
try:
|
1137 |
+
logger.info(f"start, voice name: {voice_name}, try: {i + 1}")
|
1138 |
+
|
1139 |
+
import azure.cognitiveservices.speech as speechsdk
|
1140 |
+
|
1141 |
+
sub_maker = SubMaker()
|
1142 |
+
|
1143 |
+
def speech_synthesizer_word_boundary_cb(evt: speechsdk.SessionEventArgs):
|
1144 |
+
duration = _format_duration_to_offset(str(evt.duration))
|
1145 |
+
offset = _format_duration_to_offset(evt.audio_offset)
|
1146 |
+
sub_maker.subs.append(evt.text)
|
1147 |
+
sub_maker.offset.append((offset, offset + duration))
|
1148 |
+
|
1149 |
+
# Creates an instance of a speech config with specified subscription key and service region.
|
1150 |
+
speech_key = config.azure.get("speech_key", "")
|
1151 |
+
service_region = config.azure.get("speech_region", "")
|
1152 |
+
audio_config = speechsdk.audio.AudioOutputConfig(
|
1153 |
+
filename=voice_file, use_default_speaker=True
|
1154 |
+
)
|
1155 |
+
speech_config = speechsdk.SpeechConfig(
|
1156 |
+
subscription=speech_key, region=service_region
|
1157 |
+
)
|
1158 |
+
speech_config.speech_synthesis_voice_name = voice_name
|
1159 |
+
# speech_config.set_property(property_id=speechsdk.PropertyId.SpeechServiceResponse_RequestSentenceBoundary,
|
1160 |
+
# value='true')
|
1161 |
+
speech_config.set_property(
|
1162 |
+
property_id=speechsdk.PropertyId.SpeechServiceResponse_RequestWordBoundary,
|
1163 |
+
value="true",
|
1164 |
+
)
|
1165 |
+
|
1166 |
+
speech_config.set_speech_synthesis_output_format(
|
1167 |
+
speechsdk.SpeechSynthesisOutputFormat.Audio48Khz192KBitRateMonoMp3
|
1168 |
+
)
|
1169 |
+
speech_synthesizer = speechsdk.SpeechSynthesizer(
|
1170 |
+
audio_config=audio_config, speech_config=speech_config
|
1171 |
+
)
|
1172 |
+
speech_synthesizer.synthesis_word_boundary.connect(
|
1173 |
+
speech_synthesizer_word_boundary_cb
|
1174 |
+
)
|
1175 |
+
|
1176 |
+
result = speech_synthesizer.speak_text_async(text).get()
|
1177 |
+
if result.reason == speechsdk.ResultReason.SynthesizingAudioCompleted:
|
1178 |
+
logger.success(f"azure v2 speech synthesis succeeded: {voice_file}")
|
1179 |
+
return sub_maker
|
1180 |
+
elif result.reason == speechsdk.ResultReason.Canceled:
|
1181 |
+
cancellation_details = result.cancellation_details
|
1182 |
+
logger.error(
|
1183 |
+
f"azure v2 speech synthesis canceled: {cancellation_details.reason}"
|
1184 |
+
)
|
1185 |
+
if cancellation_details.reason == speechsdk.CancellationReason.Error:
|
1186 |
+
logger.error(
|
1187 |
+
f"azure v2 speech synthesis error: {cancellation_details.error_details}"
|
1188 |
+
)
|
1189 |
+
if i < 2: # 如果不是最后一次重试,则等待1秒
|
1190 |
+
time.sleep(1)
|
1191 |
+
logger.info(f"completed, output file: {voice_file}")
|
1192 |
+
except Exception as e:
|
1193 |
+
logger.error(f"failed, error: {str(e)}")
|
1194 |
+
if i < 2: # 如果不是最后一次重试,则等待1秒
|
1195 |
+
time.sleep(3)
|
1196 |
+
return None
|
1197 |
+
|
1198 |
+
|
1199 |
+
def _format_text(text: str) -> str:
|
1200 |
+
text = text.replace("\n", " ")
|
1201 |
+
text = text.replace("\"", " ")
|
1202 |
+
text = text.replace("[", " ")
|
1203 |
+
text = text.replace("]", " ")
|
1204 |
+
text = text.replace("(", " ")
|
1205 |
+
text = text.replace(")", " ")
|
1206 |
+
text = text.replace(")", " ")
|
1207 |
+
text = text.replace("(", " ")
|
1208 |
+
text = text.replace("{", " ")
|
1209 |
+
text = text.replace("}", " ")
|
1210 |
+
text = text.strip()
|
1211 |
+
return text
|
1212 |
+
|
1213 |
+
|
1214 |
+
def create_subtitle_from_multiple(text: str, sub_maker_list: List[SubMaker], list_script: List[dict],
|
1215 |
+
subtitle_file: str):
|
1216 |
+
"""
|
1217 |
+
根据多个 SubMaker 对象、完整文本和原始脚本创建优化的字幕文件
|
1218 |
+
1. 使用原始脚本中的时间戳
|
1219 |
+
2. 跳过 OST 为 true 的部分
|
1220 |
+
3. 将字幕文件按照标点符号分割成多行
|
1221 |
+
4. 根据完整文本分段,保持原文的语句结构
|
1222 |
+
5. 生成新的字幕文件,时间戳包含小时单位
|
1223 |
+
"""
|
1224 |
+
text = _format_text(text)
|
1225 |
+
sentences = utils.split_string_by_punctuations(text)
|
1226 |
+
|
1227 |
+
def formatter(idx: int, start_time: str, end_time: str, sub_text: str) -> str:
|
1228 |
+
return f"{idx}\n{start_time.replace('.', ',')} --> {end_time.replace('.', ',')}\n{sub_text}\n"
|
1229 |
+
|
1230 |
+
sub_items = []
|
1231 |
+
sub_index = 0
|
1232 |
+
sentence_index = 0
|
1233 |
+
|
1234 |
+
try:
|
1235 |
+
sub_maker_index = 0
|
1236 |
+
for script_item in list_script:
|
1237 |
+
if script_item['OST']:
|
1238 |
+
continue
|
1239 |
+
|
1240 |
+
start_time, end_time = script_item['timestamp'].split('-')
|
1241 |
+
if sub_maker_index >= len(sub_maker_list):
|
1242 |
+
logger.error(f"Sub maker list index out of range: {sub_maker_index}")
|
1243 |
+
break
|
1244 |
+
sub_maker = sub_maker_list[sub_maker_index]
|
1245 |
+
sub_maker_index += 1
|
1246 |
+
|
1247 |
+
script_duration = utils.time_to_seconds(end_time) - utils.time_to_seconds(start_time)
|
1248 |
+
audio_duration = get_audio_duration(sub_maker)
|
1249 |
+
time_ratio = script_duration / audio_duration if audio_duration > 0 else 1
|
1250 |
+
|
1251 |
+
current_sub = ""
|
1252 |
+
current_start = None
|
1253 |
+
current_end = None
|
1254 |
+
|
1255 |
+
for offset, sub in zip(sub_maker.offset, sub_maker.subs):
|
1256 |
+
sub = unescape(sub).strip()
|
1257 |
+
sub_start = utils.seconds_to_time(utils.time_to_seconds(start_time) + offset[0] / 10000000 * time_ratio)
|
1258 |
+
sub_end = utils.seconds_to_time(utils.time_to_seconds(start_time) + offset[1] / 10000000 * time_ratio)
|
1259 |
+
|
1260 |
+
if current_start is None:
|
1261 |
+
current_start = sub_start
|
1262 |
+
current_end = sub_end
|
1263 |
+
|
1264 |
+
current_sub += sub
|
1265 |
+
|
1266 |
+
# 检查当前累积的字幕是否匹配下一个句子
|
1267 |
+
while sentence_index < len(sentences) and sentences[sentence_index] in current_sub:
|
1268 |
+
sub_index += 1
|
1269 |
+
line = formatter(
|
1270 |
+
idx=sub_index,
|
1271 |
+
start_time=current_start,
|
1272 |
+
end_time=current_end,
|
1273 |
+
sub_text=sentences[sentence_index].strip(),
|
1274 |
+
)
|
1275 |
+
sub_items.append(line)
|
1276 |
+
current_sub = current_sub.replace(sentences[sentence_index], "", 1).strip()
|
1277 |
+
current_start = current_end
|
1278 |
+
sentence_index += 1
|
1279 |
+
|
1280 |
+
# 如果当前字幕长度超过15个字符,也生成一个新的字幕项
|
1281 |
+
if len(current_sub) > 15:
|
1282 |
+
sub_index += 1
|
1283 |
+
line = formatter(
|
1284 |
+
idx=sub_index,
|
1285 |
+
start_time=current_start,
|
1286 |
+
end_time=current_end,
|
1287 |
+
sub_text=current_sub.strip(),
|
1288 |
+
)
|
1289 |
+
sub_items.append(line)
|
1290 |
+
current_sub = ""
|
1291 |
+
current_start = current_end
|
1292 |
+
|
1293 |
+
# 处理剩余的文本
|
1294 |
+
if current_sub.strip():
|
1295 |
+
sub_index += 1
|
1296 |
+
line = formatter(
|
1297 |
+
idx=sub_index,
|
1298 |
+
start_time=current_start,
|
1299 |
+
end_time=current_end,
|
1300 |
+
sub_text=current_sub.strip(),
|
1301 |
+
)
|
1302 |
+
sub_items.append(line)
|
1303 |
+
|
1304 |
+
if len(sub_items) == 0:
|
1305 |
+
logger.error("No subtitle items generated")
|
1306 |
+
return
|
1307 |
+
|
1308 |
+
with open(subtitle_file, "w", encoding="utf-8") as file:
|
1309 |
+
file.write("\n".join(sub_items))
|
1310 |
+
|
1311 |
+
logger.info(f"completed, subtitle file created: {subtitle_file}")
|
1312 |
+
except Exception as e:
|
1313 |
+
logger.error(f"failed, error: {str(e)}")
|
1314 |
+
traceback.print_exc()
|
1315 |
+
|
1316 |
+
|
1317 |
+
def create_subtitle(sub_maker: submaker.SubMaker, text: str, subtitle_file: str):
|
1318 |
+
"""
|
1319 |
+
优化字幕文件
|
1320 |
+
1. 将字幕文件按照标点符号分割成多行
|
1321 |
+
2. 逐行匹配字幕文件中的文本
|
1322 |
+
3. 生成新的字幕文件
|
1323 |
+
"""
|
1324 |
+
|
1325 |
+
text = _format_text(text)
|
1326 |
+
|
1327 |
+
def formatter(idx: int, start_time: float, end_time: float, sub_text: str) -> str:
|
1328 |
+
"""
|
1329 |
+
1
|
1330 |
+
00:00:00,000 --> 00:00:02,360
|
1331 |
+
跑步是一项简单易行的运动
|
1332 |
+
"""
|
1333 |
+
start_t = mktimestamp(start_time).replace(".", ",")
|
1334 |
+
end_t = mktimestamp(end_time).replace(".", ",")
|
1335 |
+
return f"{idx}\n" f"{start_t} --> {end_t}\n" f"{sub_text}\n"
|
1336 |
+
|
1337 |
+
start_time = -1.0
|
1338 |
+
sub_items = []
|
1339 |
+
sub_index = 0
|
1340 |
+
|
1341 |
+
script_lines = utils.split_string_by_punctuations(text)
|
1342 |
+
|
1343 |
+
def match_line(_sub_line: str, _sub_index: int):
|
1344 |
+
if len(script_lines) <= _sub_index:
|
1345 |
+
return ""
|
1346 |
+
|
1347 |
+
_line = script_lines[_sub_index]
|
1348 |
+
if _sub_line == _line:
|
1349 |
+
return script_lines[_sub_index].strip()
|
1350 |
+
|
1351 |
+
_sub_line_ = re.sub(r"[^\w\s]", "", _sub_line)
|
1352 |
+
_line_ = re.sub(r"[^\w\s]", "", _line)
|
1353 |
+
if _sub_line_ == _line_:
|
1354 |
+
return _line_.strip()
|
1355 |
+
|
1356 |
+
_sub_line_ = re.sub(r"\W+", "", _sub_line)
|
1357 |
+
_line_ = re.sub(r"\W+", "", _line)
|
1358 |
+
if _sub_line_ == _line_:
|
1359 |
+
return _line.strip()
|
1360 |
+
|
1361 |
+
return ""
|
1362 |
+
|
1363 |
+
sub_line = ""
|
1364 |
+
|
1365 |
+
try:
|
1366 |
+
for _, (offset, sub) in enumerate(zip(sub_maker.offset, sub_maker.subs)):
|
1367 |
+
_start_time, end_time = offset
|
1368 |
+
if start_time < 0:
|
1369 |
+
start_time = _start_time
|
1370 |
+
|
1371 |
+
sub = unescape(sub)
|
1372 |
+
sub_line += sub
|
1373 |
+
sub_text = match_line(sub_line, sub_index)
|
1374 |
+
if sub_text:
|
1375 |
+
sub_index += 1
|
1376 |
+
line = formatter(
|
1377 |
+
idx=sub_index,
|
1378 |
+
start_time=start_time,
|
1379 |
+
end_time=end_time,
|
1380 |
+
sub_text=sub_text,
|
1381 |
+
)
|
1382 |
+
sub_items.append(line)
|
1383 |
+
start_time = -1.0
|
1384 |
+
sub_line = ""
|
1385 |
+
|
1386 |
+
if len(sub_items) == len(script_lines):
|
1387 |
+
with open(subtitle_file, "w", encoding="utf-8") as file:
|
1388 |
+
file.write("\n".join(sub_items) + "\n")
|
1389 |
+
try:
|
1390 |
+
sbs = subtitles.file_to_subtitles(subtitle_file, encoding="utf-8")
|
1391 |
+
duration = max([tb for ((ta, tb), txt) in sbs])
|
1392 |
+
logger.info(
|
1393 |
+
f"已创建字幕文件: {subtitle_file}, duration: {duration}"
|
1394 |
+
)
|
1395 |
+
return subtitle_file, duration
|
1396 |
+
except Exception as e:
|
1397 |
+
logger.error(f"failed, error: {str(e)}")
|
1398 |
+
os.remove(subtitle_file)
|
1399 |
+
else:
|
1400 |
+
logger.error(
|
1401 |
+
f"字幕创建失败, 字幕长度: {len(sub_items)}, script_lines len: {len(script_lines)}"
|
1402 |
+
f"\nsub_items:{json.dumps(sub_items, indent=4, ensure_ascii=False)}"
|
1403 |
+
f"\nscript_lines:{json.dumps(script_lines, indent=4, ensure_ascii=False)}"
|
1404 |
+
)
|
1405 |
+
|
1406 |
+
except Exception as e:
|
1407 |
+
logger.error(f"failed, error: {str(e)}")
|
1408 |
+
|
1409 |
+
|
1410 |
+
def get_audio_duration(sub_maker: submaker.SubMaker):
|
1411 |
+
"""
|
1412 |
+
获取��频时长
|
1413 |
+
"""
|
1414 |
+
if not sub_maker.offset:
|
1415 |
+
return 0.0
|
1416 |
+
return sub_maker.offset[-1][1] / 10000000
|
1417 |
+
|
1418 |
+
|
1419 |
+
def tts_multiple(task_id: str, list_script: list, voice_name: str, voice_rate: float, voice_pitch: float):
|
1420 |
+
"""
|
1421 |
+
根据JSON文件中的多段文本进行TTS转换
|
1422 |
+
|
1423 |
+
:param task_id: 任务ID
|
1424 |
+
:param list_script: 脚本列表
|
1425 |
+
:param voice_name: 语音名称
|
1426 |
+
:param voice_rate: 语音速率
|
1427 |
+
:return: 生成的音频文件列表
|
1428 |
+
"""
|
1429 |
+
voice_name = parse_voice_name(voice_name)
|
1430 |
+
output_dir = utils.task_dir(task_id)
|
1431 |
+
tts_results = []
|
1432 |
+
|
1433 |
+
for item in list_script:
|
1434 |
+
if item['OST'] != 1:
|
1435 |
+
# 将时间戳中的冒号替换为下划线
|
1436 |
+
timestamp = item['timestamp'].replace(':', '_')
|
1437 |
+
audio_file = os.path.join(output_dir, f"audio_{timestamp}.mp3")
|
1438 |
+
subtitle_file = os.path.join(output_dir, f"subtitle_{timestamp}.srt")
|
1439 |
+
|
1440 |
+
text = item['narration']
|
1441 |
+
|
1442 |
+
sub_maker = tts(
|
1443 |
+
text=text,
|
1444 |
+
voice_name=voice_name,
|
1445 |
+
voice_rate=voice_rate,
|
1446 |
+
voice_pitch=voice_pitch,
|
1447 |
+
voice_file=audio_file,
|
1448 |
+
)
|
1449 |
+
|
1450 |
+
if sub_maker is None:
|
1451 |
+
logger.error(f"无法为时间戳 {timestamp} 生成音频; "
|
1452 |
+
f"如果您在中国,请使用VPN; "
|
1453 |
+
f"或者使用其他 tts 引擎")
|
1454 |
+
continue
|
1455 |
+
else:
|
1456 |
+
# 为当前片段生成字幕文件
|
1457 |
+
_, duration = create_subtitle(sub_maker=sub_maker, text=text, subtitle_file=subtitle_file)
|
1458 |
+
|
1459 |
+
tts_results.append({
|
1460 |
+
"_id": item['_id'],
|
1461 |
+
"timestamp": item['timestamp'],
|
1462 |
+
"audio_file": audio_file,
|
1463 |
+
"subtitle_file": subtitle_file,
|
1464 |
+
"duration": duration,
|
1465 |
+
"text": text,
|
1466 |
+
})
|
1467 |
+
logger.info(f"已生成音频文件: {audio_file}")
|
1468 |
+
|
1469 |
+
return tts_results
|