Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
63c3402c94 | ||
|
|
5a6dd6c7a5 | ||
|
|
8c226322a0 | ||
|
|
3a7888937f | ||
|
|
6760a0ad00 | ||
|
|
6288b70ae2 | ||
|
|
4adc010388 | ||
|
|
162b5e17c3 | ||
|
|
0d43ba2124 | ||
|
|
080d8d82b4 | ||
|
|
fc50e16bc5 | ||
|
|
345b6d59a1 | ||
|
|
4ec19fd56a | ||
|
|
136630ec60 | ||
|
|
9d3d99a595 | ||
|
|
747c745ec0 | ||
|
|
a53ca843e8 | ||
|
|
8b18d84d8a | ||
|
|
edc4df6eb5 | ||
|
|
5ed98d317c | ||
|
|
c22ef5f1d2 | ||
|
|
bcc9621976 |
@@ -1,5 +1,5 @@
|
|||||||
# Use an official Python runtime as a parent image
|
# Use an official Python runtime as a parent image
|
||||||
FROM python:3.10-slim-bullseye
|
FROM python:3.11-slim-bullseye
|
||||||
|
|
||||||
# Set the working directory in the container
|
# Set the working directory in the container
|
||||||
WORKDIR /MoneyPrinterTurbo
|
WORKDIR /MoneyPrinterTurbo
|
||||||
|
|||||||
@@ -152,6 +152,8 @@ cd MoneyPrinterTurbo
|
|||||||
docker-compose up
|
docker-compose up
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> Note:The latest version of docker will automatically install docker compose in the form of a plug-in, and the start command is adjusted to `docker compose up `
|
||||||
|
|
||||||
#### ② Access the Web Interface
|
#### ② Access the Web Interface
|
||||||
|
|
||||||
Open your browser and visit http://0.0.0.0:8501
|
Open your browser and visit http://0.0.0.0:8501
|
||||||
@@ -170,7 +172,7 @@ using [conda](https://conda.io/projects/conda/en/latest/user-guide/install/index
|
|||||||
```shell
|
```shell
|
||||||
git clone https://github.com/harry0703/MoneyPrinterTurbo.git
|
git clone https://github.com/harry0703/MoneyPrinterTurbo.git
|
||||||
cd MoneyPrinterTurbo
|
cd MoneyPrinterTurbo
|
||||||
conda create -n MoneyPrinterTurbo python=3.10
|
conda create -n MoneyPrinterTurbo python=3.11
|
||||||
conda activate MoneyPrinterTurbo
|
conda activate MoneyPrinterTurbo
|
||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -121,11 +121,10 @@
|
|||||||
|
|
||||||
## 快速开始 🚀
|
## 快速开始 🚀
|
||||||
|
|
||||||
下载一键启动包,解压直接使用(路径不要有 **中文** 和 **空格**)
|
下载一键启动包,解压直接使用(路径不要有 **中文**、**特殊字符**、**空格**)
|
||||||
|
|
||||||
### Windows
|
### Windows
|
||||||
|
- 百度网盘(1.2.1 最新版本): https://pan.baidu.com/s/1pSNjxTYiVENulTLm6zieMQ?pwd=g36q 提取码: g36q
|
||||||
- 百度网盘: https://pan.baidu.com/s/1MzBmcLTmVWohPEp9ohvvzA?pwd=pdcu 提取码: pdcu
|
|
||||||
|
|
||||||
下载后,建议先**双击执行** `update.bat` 更新到**最新代码**,然后双击 `start.bat` 启动
|
下载后,建议先**双击执行** `update.bat` 更新到**最新代码**,然后双击 `start.bat` 启动
|
||||||
|
|
||||||
@@ -170,6 +169,8 @@ cd MoneyPrinterTurbo
|
|||||||
docker-compose up
|
docker-compose up
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> 注意:最新版的docker安装时会自动以插件的形式安装docker compose,启动命令调整为docker compose up
|
||||||
|
|
||||||
#### ② 访问Web界面
|
#### ② 访问Web界面
|
||||||
|
|
||||||
打开浏览器,访问 http://0.0.0.0:8501
|
打开浏览器,访问 http://0.0.0.0:8501
|
||||||
@@ -192,7 +193,7 @@ docker-compose up
|
|||||||
```shell
|
```shell
|
||||||
git clone https://github.com/harry0703/MoneyPrinterTurbo.git
|
git clone https://github.com/harry0703/MoneyPrinterTurbo.git
|
||||||
cd MoneyPrinterTurbo
|
cd MoneyPrinterTurbo
|
||||||
conda create -n MoneyPrinterTurbo python=3.10
|
conda create -n MoneyPrinterTurbo python=3.11
|
||||||
conda activate MoneyPrinterTurbo
|
conda activate MoneyPrinterTurbo
|
||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ project_description = _cfg.get(
|
|||||||
"project_description",
|
"project_description",
|
||||||
"<a href='https://github.com/harry0703/MoneyPrinterTurbo'>https://github.com/harry0703/MoneyPrinterTurbo</a>",
|
"<a href='https://github.com/harry0703/MoneyPrinterTurbo'>https://github.com/harry0703/MoneyPrinterTurbo</a>",
|
||||||
)
|
)
|
||||||
project_version = _cfg.get("project_version", "1.2.0")
|
project_version = _cfg.get("project_version", "1.2.2")
|
||||||
reload_debug = False
|
reload_debug = False
|
||||||
|
|
||||||
imagemagick_path = app.get("imagemagick_path", "")
|
imagemagick_path = app.get("imagemagick_path", "")
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import warnings
|
import warnings
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import Any, List, Optional
|
from typing import Any, List, Optional, Union
|
||||||
|
|
||||||
import pydantic
|
import pydantic
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
@@ -122,7 +122,7 @@ class VideoParams(BaseModel):
|
|||||||
custom_position: float = 70.0
|
custom_position: float = 70.0
|
||||||
font_name: Optional[str] = "STHeitiMedium.ttc"
|
font_name: Optional[str] = "STHeitiMedium.ttc"
|
||||||
text_fore_color: Optional[str] = "#FFFFFF"
|
text_fore_color: Optional[str] = "#FFFFFF"
|
||||||
text_background_color: Optional[str] = "transparent"
|
text_background_color: Union[bool, str] = True
|
||||||
|
|
||||||
font_size: int = 60
|
font_size: int = 60
|
||||||
stroke_color: Optional[str] = "#000000"
|
stroke_color: Optional[str] = "#000000"
|
||||||
@@ -143,7 +143,7 @@ class SubtitleRequest(BaseModel):
|
|||||||
subtitle_position: Optional[str] = "bottom"
|
subtitle_position: Optional[str] = "bottom"
|
||||||
font_name: Optional[str] = "STHeitiMedium.ttc"
|
font_name: Optional[str] = "STHeitiMedium.ttc"
|
||||||
text_fore_color: Optional[str] = "#FFFFFF"
|
text_fore_color: Optional[str] = "#FFFFFF"
|
||||||
text_background_color: Optional[str] = "transparent"
|
text_background_color: Union[bool, str] = True
|
||||||
font_size: int = 60
|
font_size: int = 60
|
||||||
stroke_color: Optional[str] = "#000000"
|
stroke_color: Optional[str] = "#000000"
|
||||||
stroke_width: float = 1.5
|
stroke_width: float = 1.5
|
||||||
|
|||||||
@@ -40,7 +40,10 @@ def search_videos_pexels(
|
|||||||
video_orientation = aspect.name
|
video_orientation = aspect.name
|
||||||
video_width, video_height = aspect.to_resolution()
|
video_width, video_height = aspect.to_resolution()
|
||||||
api_key = get_api_key("pexels_api_keys")
|
api_key = get_api_key("pexels_api_keys")
|
||||||
headers = {"Authorization": api_key}
|
headers = {
|
||||||
|
"Authorization": api_key,
|
||||||
|
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115.0.0.0 Safari/537.36"
|
||||||
|
}
|
||||||
# Build URL
|
# Build URL
|
||||||
params = {"query": search_term, "per_page": 20, "orientation": video_orientation}
|
params = {"query": search_term, "per_page": 20, "orientation": video_orientation}
|
||||||
query_url = f"https://api.pexels.com/videos/search?{urlencode(params)}"
|
query_url = f"https://api.pexels.com/videos/search?{urlencode(params)}"
|
||||||
@@ -158,11 +161,15 @@ def save_video(video_url: str, save_dir: str = "") -> str:
|
|||||||
logger.info(f"video already exists: {video_path}")
|
logger.info(f"video already exists: {video_path}")
|
||||||
return video_path
|
return video_path
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115.0.0.0 Safari/537.36"
|
||||||
|
}
|
||||||
|
|
||||||
# if video does not exist, download it
|
# if video does not exist, download it
|
||||||
with open(video_path, "wb") as f:
|
with open(video_path, "wb") as f:
|
||||||
f.write(
|
f.write(
|
||||||
requests.get(
|
requests.get(
|
||||||
video_url, proxies=config.proxy, verify=False, timeout=(60, 240)
|
video_url, headers=headers, proxies=config.proxy, verify=False, timeout=(60, 240)
|
||||||
).content
|
).content
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import os.path
|
|||||||
import re
|
import re
|
||||||
from os import path
|
from os import path
|
||||||
|
|
||||||
|
from edge_tts import SubMaker
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from app.config import config
|
from app.config import config
|
||||||
@@ -87,10 +88,10 @@ def generate_audio(task_id, params, video_script):
|
|||||||
2. check if the network is available. If you are in China, it is recommended to use a VPN and enable the global traffic mode.
|
2. check if the network is available. If you are in China, it is recommended to use a VPN and enable the global traffic mode.
|
||||||
""".strip()
|
""".strip()
|
||||||
)
|
)
|
||||||
return None, None
|
return None, None, None
|
||||||
|
|
||||||
audio_duration = math.ceil(voice.get_audio_duration(sub_maker))
|
audio_duration = math.ceil(voice.get_audio_duration(sub_maker))
|
||||||
return audio_file, audio_duration
|
return audio_file, audio_duration, sub_maker
|
||||||
|
|
||||||
|
|
||||||
def generate_subtitle(task_id, params, video_script, sub_maker, audio_file):
|
def generate_subtitle(task_id, params, video_script, sub_maker, audio_file):
|
||||||
@@ -157,7 +158,7 @@ def get_video_materials(task_id, params, video_terms, audio_duration):
|
|||||||
|
|
||||||
|
|
||||||
def generate_final_videos(
|
def generate_final_videos(
|
||||||
task_id, params, downloaded_videos, audio_file, subtitle_path
|
task_id, params, downloaded_videos, audio_file, subtitle_path
|
||||||
):
|
):
|
||||||
final_video_paths = []
|
final_video_paths = []
|
||||||
combined_video_paths = []
|
combined_video_paths = []
|
||||||
@@ -209,6 +210,9 @@ def start(task_id, params: VideoParams, stop_at: str = "video"):
|
|||||||
logger.info(f"start task: {task_id}, stop_at: {stop_at}")
|
logger.info(f"start task: {task_id}, stop_at: {stop_at}")
|
||||||
sm.state.update_task(task_id, state=const.TASK_STATE_PROCESSING, progress=5)
|
sm.state.update_task(task_id, state=const.TASK_STATE_PROCESSING, progress=5)
|
||||||
|
|
||||||
|
if type(params.video_concat_mode) is str:
|
||||||
|
params.video_concat_mode = VideoConcatMode(params.video_concat_mode)
|
||||||
|
|
||||||
# 1. Generate script
|
# 1. Generate script
|
||||||
video_script = generate_script(task_id, params)
|
video_script = generate_script(task_id, params)
|
||||||
if not video_script:
|
if not video_script:
|
||||||
@@ -242,7 +246,7 @@ def start(task_id, params: VideoParams, stop_at: str = "video"):
|
|||||||
sm.state.update_task(task_id, state=const.TASK_STATE_PROCESSING, progress=20)
|
sm.state.update_task(task_id, state=const.TASK_STATE_PROCESSING, progress=20)
|
||||||
|
|
||||||
# 3. Generate audio
|
# 3. Generate audio
|
||||||
audio_file, audio_duration = generate_audio(task_id, params, video_script)
|
audio_file, audio_duration, sub_maker = generate_audio(task_id, params, video_script)
|
||||||
if not audio_file:
|
if not audio_file:
|
||||||
sm.state.update_task(task_id, state=const.TASK_STATE_FAILED)
|
sm.state.update_task(task_id, state=const.TASK_STATE_FAILED)
|
||||||
return
|
return
|
||||||
@@ -259,7 +263,7 @@ def start(task_id, params: VideoParams, stop_at: str = "video"):
|
|||||||
return {"audio_file": audio_file, "audio_duration": audio_duration}
|
return {"audio_file": audio_file, "audio_duration": audio_duration}
|
||||||
|
|
||||||
# 4. Generate subtitle
|
# 4. Generate subtitle
|
||||||
subtitle_path = generate_subtitle(task_id, params, video_script, None, audio_file)
|
subtitle_path = generate_subtitle(task_id, params, video_script, sub_maker, audio_file)
|
||||||
|
|
||||||
if stop_at == "subtitle":
|
if stop_at == "subtitle":
|
||||||
sm.state.update_task(
|
sm.state.update_task(
|
||||||
@@ -318,3 +322,14 @@ def start(task_id, params: VideoParams, stop_at: str = "video"):
|
|||||||
task_id, state=const.TASK_STATE_COMPLETE, progress=100, **kwargs
|
task_id, state=const.TASK_STATE_COMPLETE, progress=100, **kwargs
|
||||||
)
|
)
|
||||||
return kwargs
|
return kwargs
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
task_id = "task_id"
|
||||||
|
params = VideoParams(
|
||||||
|
video_subject="金钱的作用",
|
||||||
|
voice_name="zh-CN-XiaoyiNeural-Female",
|
||||||
|
voice_rate=1.0,
|
||||||
|
|
||||||
|
)
|
||||||
|
start(task_id, params, stop_at="video")
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import glob
|
import glob
|
||||||
|
import os
|
||||||
import random
|
import random
|
||||||
from typing import List
|
from typing import List
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
from moviepy.editor import *
|
from moviepy import *
|
||||||
from moviepy.video.tools.subtitles import SubtitlesClip
|
from moviepy.video.tools.subtitles import SubtitlesClip
|
||||||
from PIL import ImageFont
|
from PIL import ImageFont
|
||||||
|
|
||||||
@@ -60,7 +61,7 @@ def combine_videos(
|
|||||||
|
|
||||||
while start_time < clip_duration:
|
while start_time < clip_duration:
|
||||||
end_time = min(start_time + max_clip_duration, clip_duration)
|
end_time = min(start_time + max_clip_duration, clip_duration)
|
||||||
split_clip = clip.subclip(start_time, end_time)
|
split_clip = clip.subclipped(start_time, end_time)
|
||||||
raw_clips.append(split_clip)
|
raw_clips.append(split_clip)
|
||||||
# logger.info(f"splitting from {start_time:.2f} to {end_time:.2f}, clip duration {clip_duration:.2f}, split_clip duration {split_clip.duration:.2f}")
|
# logger.info(f"splitting from {start_time:.2f} to {end_time:.2f}, clip duration {clip_duration:.2f}, split_clip duration {split_clip.duration:.2f}")
|
||||||
start_time = end_time
|
start_time = end_time
|
||||||
@@ -76,11 +77,11 @@ def combine_videos(
|
|||||||
for clip in raw_clips:
|
for clip in raw_clips:
|
||||||
# Check if clip is longer than the remaining audio
|
# Check if clip is longer than the remaining audio
|
||||||
if (audio_duration - video_duration) < clip.duration:
|
if (audio_duration - video_duration) < clip.duration:
|
||||||
clip = clip.subclip(0, (audio_duration - video_duration))
|
clip = clip.subclipped(0, (audio_duration - video_duration))
|
||||||
# Only shorten clips if the calculated clip length (req_dur) is shorter than the actual clip to prevent still image
|
# Only shorten clips if the calculated clip length (req_dur) is shorter than the actual clip to prevent still image
|
||||||
elif req_dur < clip.duration:
|
elif req_dur < clip.duration:
|
||||||
clip = clip.subclip(0, req_dur)
|
clip = clip.subclipped(0, req_dur)
|
||||||
clip = clip.set_fps(30)
|
clip = clip.with_fps(30)
|
||||||
|
|
||||||
# Not all videos are same size, so we need to resize them
|
# Not all videos are same size, so we need to resize them
|
||||||
clip_w, clip_h = clip.size
|
clip_w, clip_h = clip.size
|
||||||
@@ -90,7 +91,7 @@ def combine_videos(
|
|||||||
|
|
||||||
if clip_ratio == video_ratio:
|
if clip_ratio == video_ratio:
|
||||||
# 等比例缩放
|
# 等比例缩放
|
||||||
clip = clip.resize((video_width, video_height))
|
clip = clip.resized((video_width, video_height))
|
||||||
else:
|
else:
|
||||||
# 等比缩放视频
|
# 等比缩放视频
|
||||||
if clip_ratio > video_ratio:
|
if clip_ratio > video_ratio:
|
||||||
@@ -102,15 +103,15 @@ def combine_videos(
|
|||||||
|
|
||||||
new_width = int(clip_w * scale_factor)
|
new_width = int(clip_w * scale_factor)
|
||||||
new_height = int(clip_h * scale_factor)
|
new_height = int(clip_h * scale_factor)
|
||||||
clip_resized = clip.resize(newsize=(new_width, new_height))
|
clip_resized = clip.resized(new_size=(new_width, new_height))
|
||||||
|
|
||||||
background = ColorClip(
|
background = ColorClip(
|
||||||
size=(video_width, video_height), color=(0, 0, 0)
|
size=(video_width, video_height), color=(0, 0, 0)
|
||||||
)
|
)
|
||||||
clip = CompositeVideoClip(
|
clip = CompositeVideoClip(
|
||||||
[
|
[
|
||||||
background.set_duration(clip.duration),
|
background.with_duration(clip.duration),
|
||||||
clip_resized.set_position("center"),
|
clip_resized.with_position("center"),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -119,13 +120,13 @@ def combine_videos(
|
|||||||
)
|
)
|
||||||
|
|
||||||
if clip.duration > max_clip_duration:
|
if clip.duration > max_clip_duration:
|
||||||
clip = clip.subclip(0, max_clip_duration)
|
clip = clip.subclipped(0, max_clip_duration)
|
||||||
|
|
||||||
clips.append(clip)
|
clips.append(clip)
|
||||||
video_duration += clip.duration
|
video_duration += clip.duration
|
||||||
|
|
||||||
video_clip = concatenate_videoclips(clips)
|
video_clip = concatenate_videoclips(clips)
|
||||||
video_clip = video_clip.set_fps(30)
|
video_clip = video_clip.with_fps(30)
|
||||||
logger.info("writing")
|
logger.info("writing")
|
||||||
# https://github.com/harry0703/MoneyPrinterTurbo/issues/111#issuecomment-2032354030
|
# https://github.com/harry0703/MoneyPrinterTurbo/issues/111#issuecomment-2032354030
|
||||||
video_clip.write_videofile(
|
video_clip.write_videofile(
|
||||||
@@ -231,29 +232,30 @@ def generate_video(
|
|||||||
logger.info(f"using font: {font_path}")
|
logger.info(f"using font: {font_path}")
|
||||||
|
|
||||||
def create_text_clip(subtitle_item):
|
def create_text_clip(subtitle_item):
|
||||||
|
params.font_size = int(params.font_size)
|
||||||
|
params.stroke_width = int(params.stroke_width)
|
||||||
phrase = subtitle_item[1]
|
phrase = subtitle_item[1]
|
||||||
max_width = video_width * 0.9
|
max_width = video_width * 0.9
|
||||||
wrapped_txt, txt_height = wrap_text(
|
wrapped_txt, txt_height = wrap_text(
|
||||||
phrase, max_width=max_width, font=font_path, fontsize=params.font_size
|
phrase, max_width=max_width, font=font_path, fontsize=params.font_size
|
||||||
)
|
)
|
||||||
_clip = TextClip(
|
_clip = TextClip(
|
||||||
wrapped_txt,
|
text=wrapped_txt,
|
||||||
font=font_path,
|
font=font_path,
|
||||||
fontsize=params.font_size,
|
font_size=params.font_size,
|
||||||
color=params.text_fore_color,
|
color=params.text_fore_color,
|
||||||
bg_color=params.text_background_color,
|
bg_color=params.text_background_color,
|
||||||
stroke_color=params.stroke_color,
|
stroke_color=params.stroke_color,
|
||||||
stroke_width=params.stroke_width,
|
stroke_width=params.stroke_width,
|
||||||
print_cmd=False,
|
|
||||||
)
|
)
|
||||||
duration = subtitle_item[0][1] - subtitle_item[0][0]
|
duration = subtitle_item[0][1] - subtitle_item[0][0]
|
||||||
_clip = _clip.set_start(subtitle_item[0][0])
|
_clip = _clip.with_start(subtitle_item[0][0])
|
||||||
_clip = _clip.set_end(subtitle_item[0][1])
|
_clip = _clip.with_end(subtitle_item[0][1])
|
||||||
_clip = _clip.set_duration(duration)
|
_clip = _clip.with_duration(duration)
|
||||||
if params.subtitle_position == "bottom":
|
if params.subtitle_position == "bottom":
|
||||||
_clip = _clip.set_position(("center", video_height * 0.95 - _clip.h))
|
_clip = _clip.with_position(("center", video_height * 0.95 - _clip.h))
|
||||||
elif params.subtitle_position == "top":
|
elif params.subtitle_position == "top":
|
||||||
_clip = _clip.set_position(("center", video_height * 0.05))
|
_clip = _clip.with_position(("center", video_height * 0.05))
|
||||||
elif params.subtitle_position == "custom":
|
elif params.subtitle_position == "custom":
|
||||||
# 确保字幕完全在屏幕内
|
# 确保字幕完全在屏幕内
|
||||||
margin = 10 # 额外的边距,单位为像素
|
margin = 10 # 额外的边距,单位为像素
|
||||||
@@ -261,16 +263,25 @@ def generate_video(
|
|||||||
min_y = margin
|
min_y = margin
|
||||||
custom_y = (video_height - _clip.h) * (params.custom_position / 100)
|
custom_y = (video_height - _clip.h) * (params.custom_position / 100)
|
||||||
custom_y = max(min_y, min(custom_y, max_y)) # 限制 y 值在有效范围内
|
custom_y = max(min_y, min(custom_y, max_y)) # 限制 y 值在有效范围内
|
||||||
_clip = _clip.set_position(("center", custom_y))
|
_clip = _clip.with_position(("center", custom_y))
|
||||||
else: # center
|
else: # center
|
||||||
_clip = _clip.set_position(("center", "center"))
|
_clip = _clip.with_position(("center", "center"))
|
||||||
return _clip
|
return _clip
|
||||||
|
|
||||||
video_clip = VideoFileClip(video_path)
|
video_clip = VideoFileClip(video_path)
|
||||||
audio_clip = AudioFileClip(audio_path).volumex(params.voice_volume)
|
audio_clip = AudioFileClip(audio_path).with_effects(
|
||||||
|
[afx.MultiplyVolume(params.voice_volume)]
|
||||||
|
)
|
||||||
|
|
||||||
if subtitle_path and os.path.exists(subtitle_path):
|
if subtitle_path and os.path.exists(subtitle_path):
|
||||||
sub = SubtitlesClip(subtitles=subtitle_path, encoding="utf-8")
|
generator = lambda text: TextClip(
|
||||||
|
text=text,
|
||||||
|
font=font_path,
|
||||||
|
font_size=params.font_size,
|
||||||
|
)
|
||||||
|
sub = SubtitlesClip(
|
||||||
|
subtitles=subtitle_path, encoding="utf-8", make_textclip=generator
|
||||||
|
)
|
||||||
text_clips = []
|
text_clips = []
|
||||||
for item in sub.subtitles:
|
for item in sub.subtitles:
|
||||||
clip = create_text_clip(subtitle_item=item)
|
clip = create_text_clip(subtitle_item=item)
|
||||||
@@ -280,15 +291,18 @@ def generate_video(
|
|||||||
bgm_file = get_bgm_file(bgm_type=params.bgm_type, bgm_file=params.bgm_file)
|
bgm_file = get_bgm_file(bgm_type=params.bgm_type, bgm_file=params.bgm_file)
|
||||||
if bgm_file:
|
if bgm_file:
|
||||||
try:
|
try:
|
||||||
bgm_clip = (
|
bgm_clip = AudioFileClip(bgm_file).with_effects(
|
||||||
AudioFileClip(bgm_file).volumex(params.bgm_volume).audio_fadeout(3)
|
[
|
||||||
|
afx.MultiplyVolume(params.voice_volume),
|
||||||
|
afx.AudioFadeOut(3),
|
||||||
|
afx.AudioLoop(duration=video_clip.duration),
|
||||||
|
]
|
||||||
)
|
)
|
||||||
bgm_clip = afx.audio_loop(bgm_clip, duration=video_clip.duration)
|
|
||||||
audio_clip = CompositeAudioClip([audio_clip, bgm_clip])
|
audio_clip = CompositeAudioClip([audio_clip, bgm_clip])
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"failed to add bgm: {str(e)}")
|
logger.error(f"failed to add bgm: {str(e)}")
|
||||||
|
|
||||||
video_clip = video_clip.set_audio(audio_clip)
|
video_clip = video_clip.with_audio(audio_clip)
|
||||||
video_clip.write_videofile(
|
video_clip.write_videofile(
|
||||||
output_file,
|
output_file,
|
||||||
audio_codec="aac",
|
audio_codec="aac",
|
||||||
@@ -324,14 +338,14 @@ def preprocess_video(materials: List[MaterialInfo], clip_duration=4):
|
|||||||
# 创建一个图片剪辑,并设置持续时间为3秒钟
|
# 创建一个图片剪辑,并设置持续时间为3秒钟
|
||||||
clip = (
|
clip = (
|
||||||
ImageClip(material.url)
|
ImageClip(material.url)
|
||||||
.set_duration(clip_duration)
|
.with_duration(clip_duration)
|
||||||
.set_position("center")
|
.with_position("center")
|
||||||
)
|
)
|
||||||
# 使用resize方法来添加缩放效果。这里使用了lambda函数来使得缩放效果随时间变化。
|
# 使用resize方法来添加缩放效果。这里使用了lambda函数来使得缩放效果随时间变化。
|
||||||
# 假设我们想要从原始大小逐渐放大到120%的大小。
|
# 假设我们想要从原始大小逐渐放大到120%的大小。
|
||||||
# t代表当前时间,clip.duration为视频总时长,这里是3秒。
|
# t代表当前时间,clip.duration为视频总时长,这里是3秒。
|
||||||
# 注意:1 表示100%的大小,所以1.2表示120%的大小
|
# 注意:1 表示100%的大小,所以1.2表示120%的大小
|
||||||
zoom_clip = clip.resize(
|
zoom_clip = clip.resized(
|
||||||
lambda t: 1 + (clip_duration * 0.03) * (t / clip.duration)
|
lambda t: 1 + (clip_duration * 0.03) * (t / clip.duration)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -302,21 +302,33 @@ Gender: Female
|
|||||||
Name: en-US-AnaNeural
|
Name: en-US-AnaNeural
|
||||||
Gender: Female
|
Gender: Female
|
||||||
|
|
||||||
|
Name: en-US-AndrewMultilingualNeural
|
||||||
|
Gender: Male
|
||||||
|
|
||||||
Name: en-US-AndrewNeural
|
Name: en-US-AndrewNeural
|
||||||
Gender: Male
|
Gender: Male
|
||||||
|
|
||||||
Name: en-US-AriaNeural
|
Name: en-US-AriaNeural
|
||||||
Gender: Female
|
Gender: Female
|
||||||
|
|
||||||
|
Name: en-US-AvaMultilingualNeural
|
||||||
|
Gender: Female
|
||||||
|
|
||||||
Name: en-US-AvaNeural
|
Name: en-US-AvaNeural
|
||||||
Gender: Female
|
Gender: Female
|
||||||
|
|
||||||
|
Name: en-US-BrianMultilingualNeural
|
||||||
|
Gender: Male
|
||||||
|
|
||||||
Name: en-US-BrianNeural
|
Name: en-US-BrianNeural
|
||||||
Gender: Male
|
Gender: Male
|
||||||
|
|
||||||
Name: en-US-ChristopherNeural
|
Name: en-US-ChristopherNeural
|
||||||
Gender: Male
|
Gender: Male
|
||||||
|
|
||||||
|
Name: en-US-EmmaMultilingualNeural
|
||||||
|
Gender: Female
|
||||||
|
|
||||||
Name: en-US-EmmaNeural
|
Name: en-US-EmmaNeural
|
||||||
Gender: Female
|
Gender: Female
|
||||||
|
|
||||||
@@ -602,12 +614,24 @@ Gender: Male
|
|||||||
Name: it-IT-ElsaNeural
|
Name: it-IT-ElsaNeural
|
||||||
Gender: Female
|
Gender: Female
|
||||||
|
|
||||||
Name: it-IT-GiuseppeNeural
|
Name: it-IT-GiuseppeMultilingualNeural
|
||||||
Gender: Male
|
Gender: Male
|
||||||
|
|
||||||
Name: it-IT-IsabellaNeural
|
Name: it-IT-IsabellaNeural
|
||||||
Gender: Female
|
Gender: Female
|
||||||
|
|
||||||
|
Name: iu-Cans-CA-SiqiniqNeural
|
||||||
|
Gender: Female
|
||||||
|
|
||||||
|
Name: iu-Cans-CA-TaqqiqNeural
|
||||||
|
Gender: Male
|
||||||
|
|
||||||
|
Name: iu-Latn-CA-SiqiniqNeural
|
||||||
|
Gender: Female
|
||||||
|
|
||||||
|
Name: iu-Latn-CA-TaqqiqNeural
|
||||||
|
Gender: Male
|
||||||
|
|
||||||
Name: ja-JP-KeitaNeural
|
Name: ja-JP-KeitaNeural
|
||||||
Gender: Male
|
Gender: Male
|
||||||
|
|
||||||
@@ -644,7 +668,7 @@ Gender: Male
|
|||||||
Name: kn-IN-SapnaNeural
|
Name: kn-IN-SapnaNeural
|
||||||
Gender: Female
|
Gender: Female
|
||||||
|
|
||||||
Name: ko-KR-HyunsuNeural
|
Name: ko-KR-HyunsuMultilingualNeural
|
||||||
Gender: Male
|
Gender: Male
|
||||||
|
|
||||||
Name: ko-KR-InJoonNeural
|
Name: ko-KR-InJoonNeural
|
||||||
@@ -758,7 +782,7 @@ Gender: Male
|
|||||||
Name: pt-BR-FranciscaNeural
|
Name: pt-BR-FranciscaNeural
|
||||||
Gender: Female
|
Gender: Female
|
||||||
|
|
||||||
Name: pt-BR-ThalitaNeural
|
Name: pt-BR-ThalitaMultilingualNeural
|
||||||
Gender: Female
|
Gender: Female
|
||||||
|
|
||||||
Name: pt-PT-DuarteNeural
|
Name: pt-PT-DuarteNeural
|
||||||
|
|||||||
BIN
docs/api.jpg
|
Before Width: | Height: | Size: 252 KiB After Width: | Height: | Size: 113 KiB |
|
Before Width: | Height: | Size: 384 KiB After Width: | Height: | Size: 284 KiB |
BIN
docs/webui.jpg
|
Before Width: | Height: | Size: 340 KiB After Width: | Height: | Size: 275 KiB |
|
Before Width: | Height: | Size: 90 KiB After Width: | Height: | Size: 137 KiB |
@@ -1,26 +1,14 @@
|
|||||||
requests~=2.31.0
|
moviepy==2.1.1
|
||||||
moviepy~=2.0.0.dev2
|
streamlit==1.40.2
|
||||||
openai~=1.13.3
|
edge_tts==6.1.19
|
||||||
faster-whisper~=1.0.1
|
fastapi==0.115.6
|
||||||
edge_tts~=6.1.10
|
uvicorn==0.32.1
|
||||||
uvicorn~=0.27.1
|
openai==1.56.1
|
||||||
fastapi~=0.110.0
|
faster-whisper==1.1.0
|
||||||
tomli~=2.0.1
|
loguru==0.7.2
|
||||||
streamlit~=1.33.0
|
google.generativeai==0.8.3
|
||||||
loguru~=0.7.2
|
dashscope==1.20.14
|
||||||
aiohttp~=3.9.3
|
g4f==0.3.8.1
|
||||||
urllib3~=2.2.1
|
azure-cognitiveservices-speech==1.41.1
|
||||||
pillow~=10.3.0
|
redis==5.2.0
|
||||||
pydantic~=2.6.3
|
python-multipart==0.0.19
|
||||||
g4f~=0.3.0.4
|
|
||||||
dashscope~=1.15.0
|
|
||||||
google.generativeai~=0.4.1
|
|
||||||
python-multipart~=0.0.9
|
|
||||||
redis==5.0.3
|
|
||||||
# if you use pillow~=10.3.0, you will get "PIL.Image' has no attribute 'ANTIALIAS'" error when resize video
|
|
||||||
# please install opencv-python to fix "PIL.Image' has no attribute 'ANTIALIAS'" error
|
|
||||||
opencv-python~=4.9.0.80
|
|
||||||
# for azure speech
|
|
||||||
# https://techcommunity.microsoft.com/t5/ai-azure-ai-services-blog/9-more-realistic-ai-voices-for-conversations-now-generally/ba-p/4099471
|
|
||||||
azure-cognitiveservices-speech~=1.37.0
|
|
||||||
git-changelog~=2.5.2
|
|
||||||
@@ -479,7 +479,7 @@ with left_panel:
|
|||||||
st.session_state["video_terms"] = ", ".join(terms)
|
st.session_state["video_terms"] = ", ".join(terms)
|
||||||
|
|
||||||
params.video_terms = st.text_area(
|
params.video_terms = st.text_area(
|
||||||
tr("Video Keywords"), value=st.session_state["video_terms"], height=50
|
tr("Video Keywords"), value=st.session_state["video_terms"]
|
||||||
)
|
)
|
||||||
|
|
||||||
with middle_panel:
|
with middle_panel:
|
||||||
@@ -734,7 +734,7 @@ if start_button:
|
|||||||
scroll_to_bottom()
|
scroll_to_bottom()
|
||||||
st.stop()
|
st.stop()
|
||||||
|
|
||||||
if llm_provider != "g4f" and not config.app.get(f"{llm_provider}_api_key", ""):
|
if llm_provider != "g4f" and llm_provider != 'ollama' and not config.app.get(f"{llm_provider}_api_key", ""):
|
||||||
st.error(tr("Please Enter the LLM API Key"))
|
st.error(tr("Please Enter the LLM API Key"))
|
||||||
scroll_to_bottom()
|
scroll_to_bottom()
|
||||||
st.stop()
|
st.stop()
|
||||||
|
|||||||
81
webui/i18n/pt.json
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
{
|
||||||
|
"Language": "Português Brasileiro",
|
||||||
|
"Translation": {
|
||||||
|
"Video Script Settings": "**Configurações do Roteiro do Vídeo**",
|
||||||
|
"Video Subject": "Tema do Vídeo (Forneça uma palavra-chave, :red[a IA irá gerar automaticamente] o roteiro do vídeo)",
|
||||||
|
"Script Language": "Idioma para Gerar o Roteiro do Vídeo (a IA irá gerar automaticamente com base no idioma do seu tema)",
|
||||||
|
"Generate Video Script and Keywords": "Clique para usar a IA para gerar o [Roteiro do Vídeo] e as [Palavras-chave do Vídeo] com base no **tema**",
|
||||||
|
"Auto Detect": "Detectar Automaticamente",
|
||||||
|
"Video Script": "Roteiro do Vídeo (:blue[① Opcional, gerado pela IA ② Pontuação adequada ajuda na geração de legendas])",
|
||||||
|
"Generate Video Keywords": "Clique para usar a IA para gerar [Palavras-chave do Vídeo] com base no **roteiro**",
|
||||||
|
"Please Enter the Video Subject": "Por favor, insira o Roteiro do Vídeo primeiro",
|
||||||
|
"Generating Video Script and Keywords": "A IA está gerando o roteiro do vídeo e as palavras-chave...",
|
||||||
|
"Generating Video Keywords": "A IA está gerando as palavras-chave do vídeo...",
|
||||||
|
"Video Keywords": "Palavras-chave do Vídeo (:blue[① Opcional, gerado pela IA ② Use **vírgulas em inglês** para separar, somente em inglês])",
|
||||||
|
"Video Settings": "**Configurações do Vídeo**",
|
||||||
|
"Video Concat Mode": "Modo de Concatenação de Vídeo",
|
||||||
|
"Random": "Concatenação Aleatória (Recomendado)",
|
||||||
|
"Sequential": "Concatenação Sequencial",
|
||||||
|
"Video Ratio": "Proporção do Vídeo",
|
||||||
|
"Portrait": "Retrato 9:16",
|
||||||
|
"Landscape": "Paisagem 16:9",
|
||||||
|
"Clip Duration": "Duração Máxima dos Clipes de Vídeo (segundos)",
|
||||||
|
"Number of Videos Generated Simultaneously": "Número de Vídeos Gerados Simultaneamente",
|
||||||
|
"Audio Settings": "**Configurações de Áudio**",
|
||||||
|
"Speech Synthesis": "Voz de Síntese de Fala",
|
||||||
|
"Speech Region": "Região(:red[Obrigatório,[Obter Região](https://portal.azure.com/#view/Microsoft_Azure_ProjectOxford/CognitiveServicesHub/~/SpeechServices)])",
|
||||||
|
"Speech Key": "Chave da API(:red[Obrigatório,[Obter Chave da API](https://portal.azure.com/#view/Microsoft_Azure_ProjectOxford/CognitiveServicesHub/~/SpeechServices)])",
|
||||||
|
"Speech Volume": "Volume da Fala (1.0 representa 100%)",
|
||||||
|
"Speech Rate": "Velocidade da Fala (1.0 significa velocidade 1x)",
|
||||||
|
"Male": "Masculino",
|
||||||
|
"Female": "Feminino",
|
||||||
|
"Background Music": "Música de Fundo",
|
||||||
|
"No Background Music": "Sem Música de Fundo",
|
||||||
|
"Random Background Music": "Música de Fundo Aleatória",
|
||||||
|
"Custom Background Music": "Música de Fundo Personalizada",
|
||||||
|
"Custom Background Music File": "Por favor, insira o caminho do arquivo para a música de fundo personalizada:",
|
||||||
|
"Background Music Volume": "Volume da Música de Fundo (0.2 representa 20%, a música de fundo não deve ser muito alta)",
|
||||||
|
"Subtitle Settings": "**Configurações de Legendas**",
|
||||||
|
"Enable Subtitles": "Ativar Legendas (Se desmarcado, as configurações abaixo não terão efeito)",
|
||||||
|
"Font": "Fonte da Legenda",
|
||||||
|
"Position": "Posição da Legenda",
|
||||||
|
"Top": "Superior",
|
||||||
|
"Center": "Centralizar",
|
||||||
|
"Bottom": "Inferior (Recomendado)",
|
||||||
|
"Custom": "Posição personalizada (70, indicando 70% abaixo do topo)",
|
||||||
|
"Font Size": "Tamanho da Fonte da Legenda",
|
||||||
|
"Font Color": "Cor da Fonte da Legenda",
|
||||||
|
"Stroke Color": "Cor do Contorno da Legenda",
|
||||||
|
"Stroke Width": "Largura do Contorno da Legenda",
|
||||||
|
"Generate Video": "Gerar Vídeo",
|
||||||
|
"Video Script and Subject Cannot Both Be Empty": "O Tema do Vídeo e o Roteiro do Vídeo não podem estar ambos vazios",
|
||||||
|
"Generating Video": "Gerando vídeo, por favor aguarde...",
|
||||||
|
"Start Generating Video": "Começar a Gerar Vídeo",
|
||||||
|
"Video Generation Completed": "Geração do Vídeo Concluída",
|
||||||
|
"Video Generation Failed": "Falha na Geração do Vídeo",
|
||||||
|
"You can download the generated video from the following links": "Você pode baixar o vídeo gerado a partir dos seguintes links",
|
||||||
|
"Pexels API Key": "Chave da API do Pexels ([Obter Chave da API](https://www.pexels.com/api/))",
|
||||||
|
"Pixabay API Key": "Chave da API do Pixabay ([Obter Chave da API](https://pixabay.com/api/docs/#api_search_videos))",
|
||||||
|
"Basic Settings": "**Configurações Básicas** (:blue[Clique para expandir])",
|
||||||
|
"Language": "Idioma",
|
||||||
|
"LLM Provider": "Provedor LLM",
|
||||||
|
"API Key": "Chave da API (:red[Obrigatório])",
|
||||||
|
"Base Url": "URL Base",
|
||||||
|
"Account ID": "ID da Conta (Obter no painel do Cloudflare)",
|
||||||
|
"Model Name": "Nome do Modelo",
|
||||||
|
"Please Enter the LLM API Key": "Por favor, insira a **Chave da API LLM**",
|
||||||
|
"Please Enter the Pexels API Key": "Por favor, insira a **Chave da API do Pexels**",
|
||||||
|
"Please Enter the Pixabay API Key": "Por favor, insira a **Chave da API do Pixabay**",
|
||||||
|
"Get Help": "Se precisar de ajuda ou tiver alguma dúvida, você pode entrar no discord para obter ajuda: https://harryai.cc",
|
||||||
|
"Video Source": "Fonte do Vídeo",
|
||||||
|
"TikTok": "TikTok (Suporte para TikTok em breve)",
|
||||||
|
"Bilibili": "Bilibili (Suporte para Bilibili em breve)",
|
||||||
|
"Xiaohongshu": "Xiaohongshu (Suporte para Xiaohongshu em breve)",
|
||||||
|
"Local file": "Arquivo local",
|
||||||
|
"Play Voice": "Reproduzir Voz",
|
||||||
|
"Voice Example": "Este é um exemplo de texto para testar a síntese de fala",
|
||||||
|
"Synthesizing Voice": "Sintetizando voz, por favor aguarde...",
|
||||||
|
"TTS Provider": "Selecione o provedor de síntese de voz",
|
||||||
|
"Hide Log": "Ocultar Log"
|
||||||
|
}
|
||||||
|
}
|
||||||