mirror of
https://github.com/harry0703/MoneyPrinterTurbo.git
synced 2025-11-25 03:15:04 +08:00
init
This commit is contained in:
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
/config.toml
|
||||
/storage/
|
||||
/.idea/
|
||||
2
LICENSE
2
LICENSE
@@ -18,4 +18,4 @@ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
SOFTWARE.
|
||||
109
README.md
Normal file
109
README.md
Normal file
@@ -0,0 +1,109 @@
|
||||
# MoneyPrinterTurbo 💸
|
||||
|
||||
本地自动创建短视频,只需要提供一个视频主题或关键词,就可以全自动生成视频文案、视频素材、视频字幕、视频背景音乐,最后生成一个短视频。
|
||||
|
||||
## 效果预览 📺
|
||||
|
||||
### 竖屏 9:16
|
||||
|
||||
#### 视频演示
|
||||
|
||||
[▶️ 竖屏 9:16 Demo1 ](docs/demo-portrait-1.mp4)
|
||||
|
||||
[▶️ 竖屏 9:16 Demo2 ](docs/demo-portrait-2.mp4)
|
||||
|
||||
#### 图片预览
|
||||
|
||||
<img src="docs/demo-portrait-1.jpg" width="300">
|
||||
<img src="docs/demo-portrait-2.jpg" width="300">
|
||||
|
||||
### 横屏 16:9
|
||||
|
||||
#### 视频演示
|
||||
|
||||
[▶️ 横屏 16:9](docs/demo-landscape.mp4)
|
||||
|
||||
#### 图片预览
|
||||
|
||||
<img src="docs/demo-landscape.jpg" width="600">
|
||||
|
||||
## 安装 📥
|
||||
|
||||
建议使用 [conda](https://conda.io/projects/conda/en/latest/user-guide/install/index.html) 创建 python 虚拟环境
|
||||
|
||||
```shell
|
||||
git clone https://github.com/harry0703/MoneyPrinterTurbo.git
|
||||
cd MoneyPrinterTurbo
|
||||
conda create -n MoneyPrinterTurbo python=3.10
|
||||
conda activate MoneyPrinterTurbo
|
||||
pip install -r requirements.txt
|
||||
|
||||
cp config.example.toml config.toml
|
||||
```
|
||||
|
||||
需要先配置 `config.toml` 中的参数
|
||||
|
||||
## 使用 🚀
|
||||
|
||||
完整的使用演示视频,可以查看:https://v.douyin.com/iFhnwsKY/
|
||||
|
||||
请先确认你按照 `config.toml` 文件中的说明,配置好了 `openai_api_key` 和 `pexels_api_keys`。否则项目无法正常运行。
|
||||
|
||||
### 启动Web界面
|
||||
|
||||
```shell
|
||||
sh webui.sh
|
||||
```
|
||||
|
||||
启动后,会自动打开浏览器,效果如下图:
|
||||

|
||||
|
||||
### 启动API服务
|
||||
|
||||
```shell
|
||||
python main.py
|
||||
```
|
||||
|
||||
启动后,可以查看 `API文档` http://127.0.0.1:8080/docs
|
||||

|
||||
|
||||
## 语音合成 🗣
|
||||
|
||||
所有支持的声音列表,可以查看:[声音列表](./docs/voice-list.txt)
|
||||
|
||||
## 字幕生成 📜
|
||||
|
||||
当前支持2种字幕生成方式:
|
||||
|
||||
- edge
|
||||
- whisper
|
||||
|
||||
可以修改 `config.toml` 配置文件中的 `subtitle_provider` 进行切换,如果留空,表示不生成字幕。
|
||||
|
||||
## 背景音乐 🎵
|
||||
|
||||
用于视频的背景音乐,位于项目的 `resource/songs` 目录下。当前项目里面放了一些默认的音乐,来自于 YouTube 视频,如有侵权,请删除。
|
||||
|
||||
## 字幕字体 🅰
|
||||
|
||||
用于视频字幕的渲染,位于项目的 `resource/fonts` 目录下,你也可以放进去自己的字体。
|
||||
|
||||
## 反馈和建议 📢
|
||||
|
||||
- 可以提交 [issue](https://github.com/harry0703/MoneyPrinterTurbo/issues) 或者 [pull request](https://github.com/harry0703/MoneyPrinterTurbo/pulls)。
|
||||
- 也可以关注我的抖音号:`@网旭哈瑞.AI`
|
||||
- 我会在上面发布一些 **使用教程** 和 **纯技术** 分享。
|
||||
- 如果有更新和优化,我也会在抖音上面 **及时通知**。
|
||||
- 有问题也可以在抖音上面 **留言**,我会 **尽快回复**。
|
||||
|
||||
<img src="docs/douyin.jpg" width="500">
|
||||
|
||||
## 感谢 🙏
|
||||
|
||||
该项目基于 https://github.com/FujiwaraChoki/MoneyPrinter 重构而来,做了大量的优化,增加了更多的功能。
|
||||
感谢原作者的开源精神。
|
||||
|
||||
## License 📝
|
||||
|
||||
点击查看 [`LICENSE`](LICENSE) 文件
|
||||
|
||||
0
app/__init__.py
Normal file
0
app/__init__.py
Normal file
60
app/asgi.py
Normal file
60
app/asgi.py
Normal file
@@ -0,0 +1,60 @@
|
||||
"""Application implementation - ASGI."""
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.responses import JSONResponse
|
||||
from loguru import logger
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from app.config import config
|
||||
from app.models.exception import HttpException
|
||||
from app.router import root_api_router
|
||||
from app.utils import utils
|
||||
|
||||
|
||||
def exception_handler(request: Request, e: HttpException):
|
||||
return JSONResponse(
|
||||
status_code=e.status_code,
|
||||
content=utils.get_response(e.status_code, e.data, e.message),
|
||||
)
|
||||
|
||||
|
||||
def validation_exception_handler(request: Request, e: RequestValidationError):
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content=utils.get_response(status=400, data=e.errors(), message='field required'),
|
||||
)
|
||||
|
||||
|
||||
def get_application() -> FastAPI:
|
||||
"""Initialize FastAPI application.
|
||||
|
||||
Returns:
|
||||
FastAPI: Application object instance.
|
||||
|
||||
"""
|
||||
instance = FastAPI(
|
||||
title=config.project_name,
|
||||
description=config.project_description,
|
||||
version=config.project_version,
|
||||
debug=False,
|
||||
)
|
||||
instance.include_router(root_api_router)
|
||||
instance.add_exception_handler(HttpException, exception_handler)
|
||||
instance.add_exception_handler(RequestValidationError, validation_exception_handler)
|
||||
return instance
|
||||
|
||||
|
||||
app = get_application()
|
||||
public_dir = utils.public_dir()
|
||||
app.mount("/", StaticFiles(directory=public_dir, html=True), name="")
|
||||
|
||||
|
||||
@app.on_event("shutdown")
|
||||
def shutdown_event():
|
||||
logger.info("shutdown event")
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
def startup_event():
|
||||
logger.info("startup event")
|
||||
51
app/config/__init__.py
Normal file
51
app/config/__init__.py
Normal file
@@ -0,0 +1,51 @@
|
||||
import os
|
||||
import sys
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from app.config import config
|
||||
from app.utils import utils
|
||||
|
||||
|
||||
def __init_logger():
|
||||
_log_file = utils.storage_dir("logs/server.log")
|
||||
_lvl = config.log_level
|
||||
root_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.realpath(__file__))))
|
||||
|
||||
def format_record(record):
|
||||
# 获取日志记录中的文件全路径
|
||||
file_path = record["file"].path
|
||||
# 将绝对路径转换为相对于项目根目录的路径
|
||||
relative_path = os.path.relpath(file_path, root_dir)
|
||||
# 更新记录中的文件路径
|
||||
record["file"].path = f"./{relative_path}"
|
||||
# 返回修改后的格式字符串
|
||||
# 您可以根据需要调整这里的格式
|
||||
_format = '<green>{time:%Y-%m-%d %H:%M:%S}</> | ' + \
|
||||
'<level>{level}</> | ' + \
|
||||
'"{file.path}:{line}":<blue> {function}</> ' + \
|
||||
'- <level>{message}</>' + "\n"
|
||||
return _format
|
||||
|
||||
logger.remove()
|
||||
|
||||
logger.add(
|
||||
sys.stdout,
|
||||
level=_lvl,
|
||||
format=format_record,
|
||||
colorize=True,
|
||||
)
|
||||
|
||||
logger.add(
|
||||
_log_file,
|
||||
level=_lvl,
|
||||
format=format_record,
|
||||
rotation="00:00",
|
||||
retention="3 days",
|
||||
backtrace=True,
|
||||
diagnose=True,
|
||||
enqueue=True,
|
||||
)
|
||||
|
||||
|
||||
__init_logger()
|
||||
31
app/config/config.py
Normal file
31
app/config/config.py
Normal file
@@ -0,0 +1,31 @@
|
||||
import os
|
||||
|
||||
import tomli
|
||||
from loguru import logger
|
||||
|
||||
root_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.realpath(__file__))))
|
||||
config_file = f"{root_dir}/config.toml"
|
||||
logger.info(f"load config from file: {config_file}")
|
||||
|
||||
with open(config_file, mode="rb") as fp:
|
||||
_cfg = tomli.load(fp)
|
||||
|
||||
app = _cfg.get("app", {})
|
||||
whisper = _cfg.get("whisper", {})
|
||||
|
||||
hostname = os.uname().nodename
|
||||
|
||||
log_level = _cfg.get("log_level", "DEBUG")
|
||||
listen_host = _cfg.get("listen_host", "0.0.0.0")
|
||||
listen_port = _cfg.get("listen_port", 8080)
|
||||
project_name = _cfg.get("project_name", "MoneyPrinterTurbo")
|
||||
project_description = _cfg.get("project_description", "MoneyPrinterTurbo\n by 抖音-网旭哈瑞.AI")
|
||||
project_version = _cfg.get("project_version", "1.0.0")
|
||||
reload_debug = False
|
||||
|
||||
__cfg = {
|
||||
"hostname": hostname,
|
||||
"listen_host": listen_host,
|
||||
"listen_port": listen_port,
|
||||
}
|
||||
logger.info(__cfg)
|
||||
27
app/controllers/base.py
Normal file
27
app/controllers/base.py
Normal file
@@ -0,0 +1,27 @@
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import Request
|
||||
|
||||
from app.config import config
|
||||
from app.models.exception import HttpException
|
||||
|
||||
|
||||
def get_task_id(request: Request):
|
||||
task_id = request.headers.get('x-task-id')
|
||||
if not task_id:
|
||||
task_id = uuid4()
|
||||
return str(task_id)
|
||||
|
||||
|
||||
def get_api_key(request: Request):
|
||||
api_key = request.headers.get('x-api-key')
|
||||
return api_key
|
||||
|
||||
|
||||
def verify_token(request: Request):
|
||||
token = get_api_key(request)
|
||||
if token != config.app.get("api_key", ""):
|
||||
request_id = get_task_id(request)
|
||||
request_url = request.url
|
||||
user_agent = request.headers.get('user-agent')
|
||||
raise HttpException(task_id=request_id, status_code=401, message=f"invalid token: {request_url}, {user_agent}")
|
||||
9
app/controllers/ping.py
Normal file
9
app/controllers/ping.py
Normal file
@@ -0,0 +1,9 @@
|
||||
from fastapi import APIRouter
|
||||
from fastapi import Request
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/ping", tags=["Health Check"], description="检查服务可用性", response_description="pong")
|
||||
def ping(request: Request) -> str:
|
||||
return "pong"
|
||||
11
app/controllers/v1/base.py
Normal file
11
app/controllers/v1/base.py
Normal file
@@ -0,0 +1,11 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
|
||||
def new_router(dependencies=None):
|
||||
router = APIRouter()
|
||||
router.tags = ['V1']
|
||||
router.prefix = '/api/v1'
|
||||
# 将认证依赖项应用于所有路由
|
||||
if dependencies:
|
||||
router.dependencies = dependencies
|
||||
return router
|
||||
44
app/controllers/v1/video.py
Normal file
44
app/controllers/v1/video.py
Normal file
@@ -0,0 +1,44 @@
|
||||
from os import path
|
||||
|
||||
from fastapi import Request, Depends, Path
|
||||
from loguru import logger
|
||||
|
||||
from app.controllers import base
|
||||
from app.controllers.v1.base import new_router
|
||||
from app.models.exception import HttpException
|
||||
from app.models.schema import TaskVideoRequest, TaskQueryResponse, TaskResponse, TaskQueryRequest
|
||||
from app.services import task as tm
|
||||
from app.utils import utils
|
||||
|
||||
# 认证依赖项
|
||||
# router = new_router(dependencies=[Depends(base.verify_token)])
|
||||
router = new_router()
|
||||
|
||||
|
||||
@router.post("/videos", response_model=TaskResponse, summary="使用主题来生成短视频")
|
||||
async def create_video(request: Request, body: TaskVideoRequest):
|
||||
task_id = utils.get_uuid()
|
||||
request_id = base.get_task_id(request)
|
||||
try:
|
||||
task = {
|
||||
"task_id": task_id,
|
||||
"request_id": request_id,
|
||||
}
|
||||
body_dict = body.dict()
|
||||
task.update(body_dict)
|
||||
result = tm.start(task_id=task_id, params=body)
|
||||
task["result"] = result
|
||||
logger.success(f"video created: {utils.to_json(task)}")
|
||||
return utils.get_response(200, task)
|
||||
except ValueError as e:
|
||||
raise HttpException(task_id=task_id, status_code=400, message=f"{request_id}: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/tasks/{task_id}", response_model=TaskQueryResponse, summary="查询任务状态")
|
||||
async def get_task(request: Request, task_id: str = Path(..., description="任务ID"),
|
||||
query: TaskQueryRequest = Depends()):
|
||||
request_id = base.get_task_id(request)
|
||||
data = query.dict()
|
||||
data["task_id"] = task_id
|
||||
raise HttpException(task_id=task_id, status_code=404,
|
||||
message=f"{request_id}: task not found", data=data)
|
||||
0
app/models/__init__.py
Normal file
0
app/models/__init__.py
Normal file
4
app/models/const.py
Normal file
4
app/models/const.py
Normal file
@@ -0,0 +1,4 @@
|
||||
punctuations = [
|
||||
"?", ",", ".", "、", ";",
|
||||
"?", ",", "。", "、", ";",
|
||||
]
|
||||
26
app/models/exception.py
Normal file
26
app/models/exception.py
Normal file
@@ -0,0 +1,26 @@
|
||||
import traceback
|
||||
from typing import Any
|
||||
|
||||
from loguru import logger
|
||||
|
||||
|
||||
class HttpException(Exception):
|
||||
def __init__(self, task_id: str, status_code: int, message: str = '', data: Any = None):
|
||||
self.message = message
|
||||
self.status_code = status_code
|
||||
self.data = data
|
||||
# 获取异常堆栈信息
|
||||
tb_str = traceback.format_exc().strip()
|
||||
if not tb_str or tb_str == "NoneType: None":
|
||||
msg = f'HttpException: {status_code}, {task_id}, {message}'
|
||||
else:
|
||||
msg = f'HttpException: {status_code}, {task_id}, {message}\n{tb_str}'
|
||||
|
||||
if status_code == 400:
|
||||
logger.warning(msg)
|
||||
else:
|
||||
logger.error(msg)
|
||||
|
||||
|
||||
class FileNotFoundException(Exception):
|
||||
pass
|
||||
119
app/models/schema.py
Normal file
119
app/models/schema.py
Normal file
@@ -0,0 +1,119 @@
|
||||
from enum import Enum
|
||||
from typing import Any, Optional
|
||||
|
||||
from pydantic import BaseModel
|
||||
import warnings
|
||||
|
||||
# 忽略 Pydantic 的特定警告
|
||||
warnings.filterwarnings("ignore", category=UserWarning, message="Field name.*shadows an attribute in parent.*")
|
||||
|
||||
|
||||
class VideoAspect(str, Enum):
|
||||
landscape = "16:9"
|
||||
portrait = "9:16"
|
||||
square = "1:1"
|
||||
|
||||
def to_resolution(self):
|
||||
if self == VideoAspect.landscape.value:
|
||||
return 1920, 1080
|
||||
elif self == VideoAspect.portrait.value:
|
||||
return 1080, 1920
|
||||
elif self == VideoAspect.square.value:
|
||||
return 1080, 1080
|
||||
return 1080, 1920
|
||||
|
||||
|
||||
VoiceNames = [
|
||||
# zh-CN
|
||||
"female-zh-CN-XiaoxiaoNeural",
|
||||
"female-zh-CN-XiaoyiNeural",
|
||||
"female-zh-CN-liaoning-XiaobeiNeural",
|
||||
"female-zh-CN-shaanxi-XiaoniNeural",
|
||||
|
||||
"male-zh-CN-YunjianNeural",
|
||||
"male-zh-CN-YunxiNeural",
|
||||
"male-zh-CN-YunxiaNeural",
|
||||
"male-zh-CN-YunyangNeural",
|
||||
|
||||
# "female-zh-HK-HiuGaaiNeural",
|
||||
# "female-zh-HK-HiuMaanNeural",
|
||||
# "male-zh-HK-WanLungNeural",
|
||||
#
|
||||
# "female-zh-TW-HsiaoChenNeural",
|
||||
# "female-zh-TW-HsiaoYuNeural",
|
||||
# "male-zh-TW-YunJheNeural",
|
||||
|
||||
# en-US
|
||||
|
||||
"female-en-US-AnaNeural",
|
||||
"female-en-US-AriaNeural",
|
||||
"female-en-US-AvaNeural",
|
||||
"female-en-US-EmmaNeural",
|
||||
"female-en-US-JennyNeural",
|
||||
"female-en-US-MichelleNeural",
|
||||
|
||||
"male-en-US-AndrewNeural",
|
||||
"male-en-US-BrianNeural",
|
||||
"male-en-US-ChristopherNeural",
|
||||
"male-en-US-EricNeural",
|
||||
"male-en-US-GuyNeural",
|
||||
"male-en-US-RogerNeural",
|
||||
"male-en-US-SteffanNeural",
|
||||
]
|
||||
|
||||
|
||||
class VideoParams:
|
||||
"""
|
||||
{
|
||||
"video_subject": "",
|
||||
"video_aspect": "横屏 16:9(西瓜视频)",
|
||||
"voice_name": "女生-晓晓",
|
||||
"bgm_name": "random",
|
||||
"font_name": "STHeitiMedium 黑体-中",
|
||||
"text_color": "#FFFFFF",
|
||||
"font_size": 60,
|
||||
"stroke_color": "#000000",
|
||||
"stroke_width": 1.5
|
||||
}
|
||||
"""
|
||||
video_subject: str
|
||||
video_aspect: Optional[VideoAspect] = VideoAspect.portrait.value
|
||||
voice_name: Optional[str] = VoiceNames[0]
|
||||
bgm_name: Optional[str] = "random"
|
||||
font_name: Optional[str] = "STHeitiMedium.ttc"
|
||||
text_fore_color: Optional[str] = "#FFFFFF"
|
||||
font_size: int = 60
|
||||
stroke_color: Optional[str] = "#000000"
|
||||
stroke_width: float = 1.5
|
||||
n_threads: Optional[int] = 2
|
||||
paragraph_number: Optional[int] = 1
|
||||
|
||||
|
||||
class BaseResponse(BaseModel):
|
||||
status: int = 200
|
||||
message: Optional[str] = 'success'
|
||||
data: Any = None
|
||||
|
||||
|
||||
class TaskVideoRequest(VideoParams, BaseModel):
|
||||
pass
|
||||
|
||||
|
||||
class TaskQueryRequest(BaseModel):
|
||||
pass
|
||||
|
||||
|
||||
######################################################################################################
|
||||
######################################################################################################
|
||||
######################################################################################################
|
||||
######################################################################################################
|
||||
class TaskResponse(BaseResponse):
|
||||
class TaskResponseData(BaseModel):
|
||||
task_id: str
|
||||
task_type: str = ""
|
||||
|
||||
data: TaskResponseData
|
||||
|
||||
|
||||
class TaskQueryResponse(BaseResponse):
|
||||
pass
|
||||
15
app/router.py
Normal file
15
app/router.py
Normal file
@@ -0,0 +1,15 @@
|
||||
"""Application configuration - root APIRouter.
|
||||
|
||||
Defines all FastAPI application endpoints.
|
||||
|
||||
Resources:
|
||||
1. https://fastapi.tiangolo.com/tutorial/bigger-applications
|
||||
|
||||
"""
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.controllers.v1 import video
|
||||
|
||||
root_api_router = APIRouter()
|
||||
# v1
|
||||
root_api_router.include_router(video.router)
|
||||
0
app/services/__init__.py
Normal file
0
app/services/__init__.py
Normal file
152
app/services/gpt.py
Normal file
152
app/services/gpt.py
Normal file
@@ -0,0 +1,152 @@
|
||||
import logging
|
||||
import re
|
||||
import json
|
||||
import openai
|
||||
from typing import List
|
||||
from loguru import logger
|
||||
|
||||
from app.config import config
|
||||
|
||||
openai_api_key = config.app.get("openai_api_key")
|
||||
if not openai_api_key:
|
||||
raise ValueError("openai_api_key is not set, please set it in the config.toml file.")
|
||||
|
||||
openai_model_name = config.app.get("openai_model_name")
|
||||
if not openai_model_name:
|
||||
raise ValueError("openai_model_name is not set, please set it in the config.toml file.")
|
||||
|
||||
openai_base_url = config.app.get("openai_base_url")
|
||||
|
||||
openai.api_key = openai_api_key
|
||||
openai_model_name = openai_model_name
|
||||
if openai_base_url:
|
||||
openai.base_url = openai_base_url
|
||||
|
||||
|
||||
def _generate_response(prompt: str) -> str:
|
||||
model_name = openai_model_name
|
||||
|
||||
response = openai.chat.completions.create(
|
||||
model=model_name,
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
).choices[0].message.content
|
||||
return response
|
||||
|
||||
|
||||
def generate_script(video_subject: str, language: str = "zh-CN", paragraph_number: int = 1) -> str:
|
||||
prompt = f"""
|
||||
# Role: Video Script Generator
|
||||
|
||||
## Goals:
|
||||
Generate a script for a video, depending on the subject of the video.
|
||||
|
||||
## Constrains:
|
||||
1. the script is to be returned as a string with the specified number of paragraphs.
|
||||
2. do not under any circumstance reference this prompt in your response.
|
||||
3. get straight to the point, don't start with unnecessary things like, "welcome to this video".
|
||||
4. you must not include any type of markdown or formatting in the script, never use a title.
|
||||
5. only return the raw content of the script.
|
||||
6. do not include "voiceover", "narrator" or similar indicators of what should be spoken at the beginning of each paragraph or line.
|
||||
7. you must not mention the prompt, or anything about the script itself. also, never talk about the amount of paragraphs or lines. just write the script.
|
||||
|
||||
## Output Example:
|
||||
What is the meaning of life. This question has puzzled philosophers.
|
||||
|
||||
# Initialization:
|
||||
- video subject: {video_subject}
|
||||
- output language: {language}
|
||||
- number of paragraphs: {paragraph_number}
|
||||
""".strip()
|
||||
|
||||
final_script = ""
|
||||
logger.info(f"subject: {video_subject}")
|
||||
logger.debug(f"prompt: \n{prompt}")
|
||||
response = _generate_response(prompt=prompt)
|
||||
|
||||
# Return the generated script
|
||||
if response:
|
||||
# Clean the script
|
||||
# Remove asterisks, hashes
|
||||
response = response.replace("*", "")
|
||||
response = response.replace("#", "")
|
||||
|
||||
# Remove markdown syntax
|
||||
response = re.sub(r"\[.*\]", "", response)
|
||||
response = re.sub(r"\(.*\)", "", response)
|
||||
|
||||
# Split the script into paragraphs
|
||||
paragraphs = response.split("\n\n")
|
||||
|
||||
# Select the specified number of paragraphs
|
||||
selected_paragraphs = paragraphs[:paragraph_number]
|
||||
|
||||
# Join the selected paragraphs into a single string
|
||||
final_script = "\n\n".join(selected_paragraphs)
|
||||
|
||||
# Print to console the number of paragraphs used
|
||||
# logger.info(f"number of paragraphs used: {len(selected_paragraphs)}")
|
||||
else:
|
||||
logging.error("gpt returned an empty response")
|
||||
|
||||
logger.success(f"completed: \n{final_script}")
|
||||
return final_script
|
||||
|
||||
|
||||
def generate_terms(video_subject: str, video_script: str, amount: int = 5) -> List[str]:
|
||||
prompt = f"""
|
||||
# Role: Video Search Terms Generator
|
||||
|
||||
## Goals:
|
||||
Generate {amount} search terms for stock videos, depending on the subject of a video.
|
||||
|
||||
## Constrains:
|
||||
1. the search terms are to be returned as a json-array of strings.
|
||||
2. each search term should consist of 1-3 words, always add the main subject of the video.
|
||||
3. you must only return the json-array of strings. you must not return anything else. you must not return the script.
|
||||
4. the search terms must be related to the subject of the video.
|
||||
5. reply with english search terms only.
|
||||
|
||||
## Output Example:
|
||||
["search term 1", "search term 2", "search term 3","search term 4","search term 5"]
|
||||
|
||||
## Context:
|
||||
### Video Subject
|
||||
{video_subject}
|
||||
|
||||
### Video Script
|
||||
{video_script}
|
||||
""".strip()
|
||||
|
||||
logger.info(f"subject: {video_subject}")
|
||||
logger.debug(f"prompt: \n{prompt}")
|
||||
response = _generate_response(prompt)
|
||||
search_terms = []
|
||||
|
||||
try:
|
||||
search_terms = json.loads(response)
|
||||
if not isinstance(search_terms, list) or not all(isinstance(term, str) for term in search_terms):
|
||||
raise ValueError("response is not a list of strings.")
|
||||
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
# logger.warning(f"gpt returned an unformatted response. attempting to clean...")
|
||||
# Attempt to extract list-like string and convert to list
|
||||
match = re.search(r'\["(?:[^"\\]|\\.)*"(?:,\s*"[^"\\]*")*\]', response)
|
||||
if match:
|
||||
try:
|
||||
search_terms = json.loads(match.group())
|
||||
except json.JSONDecodeError:
|
||||
logger.error(f"could not parse response: {response}")
|
||||
return []
|
||||
|
||||
logger.success(f"completed: \n{search_terms}")
|
||||
return search_terms
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
video_subject = "生命的意义是什么"
|
||||
script = generate_script(video_subject=video_subject, language="zh-CN", paragraph_number=1)
|
||||
# print("######################")
|
||||
# print(script)
|
||||
search_terms = generate_terms(video_subject=video_subject, video_script=script, amount=5)
|
||||
# print("######################")
|
||||
# print(search_terms)
|
||||
112
app/services/material.py
Normal file
112
app/services/material.py
Normal file
@@ -0,0 +1,112 @@
|
||||
import time
|
||||
|
||||
import requests
|
||||
from typing import List
|
||||
from loguru import logger
|
||||
|
||||
from app.config import config
|
||||
from app.models.schema import VideoAspect
|
||||
from app.utils import utils
|
||||
|
||||
requested_count = 0
|
||||
pexels_api_keys = config.app.get("pexels_api_keys")
|
||||
if not pexels_api_keys:
|
||||
raise ValueError("pexels_api_keys is not set, please set it in the config.toml file.")
|
||||
|
||||
|
||||
def round_robin_api_key():
|
||||
global requested_count
|
||||
requested_count += 1
|
||||
return pexels_api_keys[requested_count % len(pexels_api_keys)]
|
||||
|
||||
|
||||
def search_videos(search_term: str,
|
||||
wanted_count: int,
|
||||
minimum_duration: int,
|
||||
video_aspect: VideoAspect = VideoAspect.portrait,
|
||||
locale: str = "zh-CN"
|
||||
) -> List[str]:
|
||||
aspect = VideoAspect(video_aspect)
|
||||
video_orientation = aspect.name
|
||||
video_width, video_height = aspect.to_resolution()
|
||||
|
||||
headers = {
|
||||
"Authorization": round_robin_api_key()
|
||||
}
|
||||
|
||||
# Build URL
|
||||
query_url = f"https://api.pexels.com/videos/search?query={search_term}&per_page=15&orientation={video_orientation}&locale={locale}"
|
||||
logger.info(f"searching videos: {query_url}")
|
||||
# Send the request
|
||||
r = requests.get(query_url, headers=headers)
|
||||
|
||||
# Parse the response
|
||||
response = r.json()
|
||||
video_urls = []
|
||||
|
||||
try:
|
||||
videos_count = min(len(response["videos"]), wanted_count)
|
||||
# loop through each video in the result
|
||||
for i in range(videos_count):
|
||||
# check if video has desired minimum duration
|
||||
if response["videos"][i]["duration"] < minimum_duration:
|
||||
continue
|
||||
video_files = response["videos"][i]["video_files"]
|
||||
# loop through each url to determine the best quality
|
||||
for video in video_files:
|
||||
# Check if video has a valid download link
|
||||
# if ".com/external" in video["link"]:
|
||||
w = int(video["width"])
|
||||
h = int(video["height"])
|
||||
if w == video_width and h == video_height:
|
||||
video_urls.append(video["link"])
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"search videos failed: {e}")
|
||||
|
||||
return video_urls
|
||||
|
||||
|
||||
def save_video(video_url: str, save_dir: str) -> str:
|
||||
video_id = f"vid-{str(int(time.time() * 1000))}"
|
||||
video_path = f"{save_dir}/{video_id}.mp4"
|
||||
with open(video_path, "wb") as f:
|
||||
f.write(requests.get(video_url).content)
|
||||
|
||||
return video_path
|
||||
|
||||
|
||||
def download_videos(task_id: str,
|
||||
search_terms: List[str],
|
||||
video_aspect: VideoAspect = VideoAspect.portrait,
|
||||
wanted_count: int = 15,
|
||||
minimum_duration: int = 5
|
||||
) -> List[str]:
|
||||
valid_video_urls = []
|
||||
for search_term in search_terms:
|
||||
# logger.info(f"searching videos for '{search_term}'")
|
||||
video_urls = search_videos(search_term=search_term,
|
||||
wanted_count=wanted_count,
|
||||
minimum_duration=minimum_duration,
|
||||
video_aspect=video_aspect)
|
||||
logger.info(f"found {len(video_urls)} videos for '{search_term}'")
|
||||
i = 0
|
||||
for url in video_urls:
|
||||
if url not in valid_video_urls:
|
||||
valid_video_urls.append(url)
|
||||
i += 1
|
||||
if i >= 3:
|
||||
break
|
||||
|
||||
logger.info(f"downloading videos: {len(valid_video_urls)}")
|
||||
video_paths = []
|
||||
save_dir = utils.task_dir(task_id)
|
||||
for video_url in valid_video_urls:
|
||||
try:
|
||||
saved_video_path = save_video(video_url, save_dir)
|
||||
video_paths.append(saved_video_path)
|
||||
except Exception as e:
|
||||
logger.error(f"failed to download video: {video_url}, {e}")
|
||||
logger.success(f"downloaded {len(video_paths)} videos")
|
||||
return video_paths
|
||||
167
app/services/subtitle.py
Normal file
167
app/services/subtitle.py
Normal file
@@ -0,0 +1,167 @@
|
||||
import json
|
||||
import re
|
||||
|
||||
from faster_whisper import WhisperModel
|
||||
from timeit import default_timer as timer
|
||||
from loguru import logger
|
||||
|
||||
from app.config import config
|
||||
from app.models import const
|
||||
from app.utils import utils
|
||||
|
||||
model_size = config.whisper.get("model_size", "large-v3")
|
||||
device = config.whisper.get("device", "cpu")
|
||||
compute_type = config.whisper.get("compute_type", "int8")
|
||||
|
||||
model = WhisperModel(model_size_or_path=model_size, device=device, compute_type=compute_type)
|
||||
|
||||
|
||||
def create(audio_file, subtitle_file: str = ""):
|
||||
logger.info(f"start, output file: {subtitle_file}")
|
||||
if not subtitle_file:
|
||||
subtitle_file = f"{audio_file}.srt"
|
||||
|
||||
segments, info = model.transcribe(
|
||||
audio_file,
|
||||
beam_size=5,
|
||||
word_timestamps=True,
|
||||
vad_filter=True,
|
||||
vad_parameters=dict(min_silence_duration_ms=500),
|
||||
)
|
||||
|
||||
logger.info(f"detected language: '{info.language}', probability: {info.language_probability:.2f}")
|
||||
|
||||
start = timer()
|
||||
subtitles = []
|
||||
|
||||
def recognized(seg_text, seg_start, seg_end):
|
||||
seg_text = seg_text.strip()
|
||||
if not seg_text:
|
||||
return
|
||||
|
||||
msg = "[%.2fs -> %.2fs] %s" % (seg_start, seg_end, seg_text)
|
||||
logger.debug(msg)
|
||||
|
||||
subtitles.append({
|
||||
"msg": seg_text,
|
||||
"start_time": seg_start,
|
||||
"end_time": seg_end
|
||||
})
|
||||
|
||||
for segment in segments:
|
||||
words_idx = 0
|
||||
words_len = len(segment.words)
|
||||
|
||||
seg_start = 0
|
||||
seg_end = 0
|
||||
seg_text = ""
|
||||
|
||||
if segment.words:
|
||||
is_segmented = False
|
||||
for word in segment.words:
|
||||
if not is_segmented:
|
||||
seg_start = word.start
|
||||
is_segmented = True
|
||||
|
||||
seg_end = word.end
|
||||
# 如果包含标点,则断句
|
||||
seg_text += word.word
|
||||
|
||||
if utils.str_contains_punctuation(word.word):
|
||||
# remove last char
|
||||
seg_text = seg_text[:-1]
|
||||
if not seg_text:
|
||||
continue
|
||||
|
||||
recognized(seg_text, seg_start, seg_end)
|
||||
|
||||
is_segmented = False
|
||||
seg_text = ""
|
||||
|
||||
if words_idx == 0 and segment.start < word.start:
|
||||
seg_start = word.start
|
||||
if words_idx == (words_len - 1) and segment.end > word.end:
|
||||
seg_end = word.end
|
||||
words_idx += 1
|
||||
|
||||
if not seg_text:
|
||||
continue
|
||||
|
||||
recognized(seg_text, seg_start, seg_end)
|
||||
|
||||
end = timer()
|
||||
|
||||
diff = end - start
|
||||
logger.info(f"complete, elapsed: {diff:.2f} s")
|
||||
|
||||
idx = 1
|
||||
lines = []
|
||||
for subtitle in subtitles:
|
||||
text = subtitle.get("msg")
|
||||
if text:
|
||||
lines.append(utils.text_to_srt(idx, text, subtitle.get("start_time"), subtitle.get("end_time")))
|
||||
idx += 1
|
||||
|
||||
sub = "\n".join(lines)
|
||||
with open(subtitle_file, "w") as f:
|
||||
f.write(sub)
|
||||
logger.info(f"subtitle file created: {subtitle_file}")
|
||||
|
||||
|
||||
def file_to_subtitles(filename):
|
||||
times_texts = []
|
||||
current_times = None
|
||||
current_text = ""
|
||||
index = 0
|
||||
with open(filename, 'r') as f:
|
||||
for line in f:
|
||||
times = re.findall("([0-9]*:[0-9]*:[0-9]*,[0-9]*)", line)
|
||||
if times:
|
||||
current_times = line
|
||||
elif line.strip() == '' and current_times:
|
||||
index += 1
|
||||
times_texts.append((index, current_times.strip(), current_text.strip()))
|
||||
current_times, current_text = None, ""
|
||||
elif current_times:
|
||||
current_text += line
|
||||
return times_texts
|
||||
|
||||
|
||||
def correct(subtitle_file, video_script):
|
||||
subtitle_items = file_to_subtitles(subtitle_file)
|
||||
script_lines = utils.split_string_by_punctuations(video_script)
|
||||
|
||||
corrected = False
|
||||
if len(subtitle_items) == len(script_lines):
|
||||
for i in range(len(script_lines)):
|
||||
script_line = script_lines[i].strip()
|
||||
subtitle_line = subtitle_items[i][2]
|
||||
if script_line != subtitle_line:
|
||||
logger.warning(f"line {i + 1}, script: {script_line}, subtitle: {subtitle_line}")
|
||||
subtitle_items[i] = (subtitle_items[i][0], subtitle_items[i][1], script_line)
|
||||
corrected = True
|
||||
|
||||
if corrected:
|
||||
with open(subtitle_file, "w") as fd:
|
||||
for item in subtitle_items:
|
||||
fd.write(f"{item[0]}\n{item[1]}\n{item[2]}\n\n")
|
||||
logger.info(f"subtitle corrected")
|
||||
else:
|
||||
logger.success(f"subtitle is correct")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
task_id = "c12fd1e6-4b0a-4d65-a075-c87abe35a072"
|
||||
task_dir = utils.task_dir(task_id)
|
||||
subtitle_file = f"{task_dir}/subtitle.srt"
|
||||
|
||||
subtitles = file_to_subtitles(subtitle_file)
|
||||
print(subtitles)
|
||||
|
||||
script_file = f"{task_dir}/script.json"
|
||||
with open(script_file, "r") as f:
|
||||
script_content = f.read()
|
||||
s = json.loads(script_content)
|
||||
script = s.get("script")
|
||||
|
||||
correct(subtitle_file, script)
|
||||
113
app/services/task.py
Normal file
113
app/services/task.py
Normal file
@@ -0,0 +1,113 @@
|
||||
from os import path
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from app.config import config
|
||||
from app.models.schema import VideoParams, VoiceNames
|
||||
from app.services import gpt, material, voice, video, subtitle
|
||||
from app.utils import utils
|
||||
|
||||
|
||||
def _parse_voice(name: str):
|
||||
# "female-zh-CN-XiaoxiaoNeural",
|
||||
# remove first part split by "-"
|
||||
if name not in VoiceNames:
|
||||
name = VoiceNames[0]
|
||||
|
||||
parts = name.split("-")
|
||||
_lang = f"{parts[1]}-{parts[2]}"
|
||||
_voice = f"{_lang}-{parts[3]}"
|
||||
|
||||
return _voice, _lang
|
||||
|
||||
|
||||
def start(task_id, params: VideoParams):
|
||||
"""
|
||||
{
|
||||
"video_subject": "",
|
||||
"video_aspect": "横屏 16:9(西瓜视频)",
|
||||
"voice_name": "女生-晓晓",
|
||||
"enable_bgm": false,
|
||||
"font_name": "STHeitiMedium 黑体-中",
|
||||
"text_color": "#FFFFFF",
|
||||
"font_size": 60,
|
||||
"stroke_color": "#000000",
|
||||
"stroke_width": 1.5
|
||||
}
|
||||
"""
|
||||
logger.info(f"start task: {task_id}")
|
||||
video_subject = params.video_subject
|
||||
voice_name, language = _parse_voice(params.voice_name)
|
||||
paragraph_number = params.paragraph_number
|
||||
n_threads = params.n_threads
|
||||
|
||||
logger.info("\n\n## generating video script")
|
||||
script = gpt.generate_script(video_subject=video_subject, language=language, paragraph_number=paragraph_number)
|
||||
|
||||
logger.info("\n\n## generating video terms")
|
||||
search_terms = gpt.generate_terms(video_subject=video_subject, video_script=script, amount=5)
|
||||
|
||||
script_file = path.join(utils.task_dir(task_id), f"script.json")
|
||||
script_data = {
|
||||
"script": script,
|
||||
"search_terms": search_terms
|
||||
}
|
||||
|
||||
with open(script_file, "w") as f:
|
||||
f.write(utils.to_json(script_data))
|
||||
|
||||
audio_file = path.join(utils.task_dir(task_id), f"audio.mp3")
|
||||
subtitle_path = path.join(utils.task_dir(task_id), f"subtitle.srt")
|
||||
|
||||
logger.info("\n\n## generating audio")
|
||||
sub_maker = voice.tts(text=script, voice_name=voice_name, voice_file=audio_file)
|
||||
|
||||
subtitle_provider = config.app.get("subtitle_provider", "").strip().lower()
|
||||
logger.info(f"\n\n## generating subtitle, provider: {subtitle_provider}")
|
||||
if subtitle_provider == "edge":
|
||||
voice.create_subtitle(text=script, sub_maker=sub_maker, subtitle_file=subtitle_path)
|
||||
if subtitle_provider == "whisper":
|
||||
subtitle.create(audio_file=audio_file, subtitle_file=subtitle_path)
|
||||
logger.info("\n\n## correcting subtitle")
|
||||
subtitle.correct(subtitle_file=subtitle_path, video_script=script)
|
||||
|
||||
logger.info("\n\n## downloading videos")
|
||||
video_paths = material.download_videos(task_id=task_id, search_terms=search_terms, video_aspect=params.video_aspect,
|
||||
wanted_count=20,
|
||||
minimum_duration=5)
|
||||
|
||||
logger.info("\n\n## combining videos")
|
||||
combined_video_path = path.join(utils.task_dir(task_id), f"combined.mp4")
|
||||
video.combine_videos(combined_video_path=combined_video_path,
|
||||
video_paths=video_paths,
|
||||
audio_file=audio_file,
|
||||
video_aspect=params.video_aspect,
|
||||
max_clip_duration=5,
|
||||
threads=n_threads)
|
||||
|
||||
final_video_path = path.join(utils.task_dir(task_id), f"final.mp4")
|
||||
|
||||
bgm_file = video.get_bgm_file(bgm_name=params.bgm_name)
|
||||
logger.info("\n\n## generating video")
|
||||
# Put everything together
|
||||
video.generate_video(video_path=combined_video_path,
|
||||
audio_path=audio_file,
|
||||
subtitle_path=subtitle_path,
|
||||
output_file=final_video_path,
|
||||
|
||||
video_aspect=params.video_aspect,
|
||||
|
||||
threads=n_threads,
|
||||
|
||||
font_name=params.font_name,
|
||||
fontsize=params.font_size,
|
||||
text_fore_color=params.text_fore_color,
|
||||
stroke_color=params.stroke_color,
|
||||
stroke_width=params.stroke_width,
|
||||
|
||||
bgm_file=bgm_file
|
||||
)
|
||||
logger.start(f"task {task_id} finished")
|
||||
return {
|
||||
"video_file": final_video_path,
|
||||
}
|
||||
246
app/services/video.py
Normal file
246
app/services/video.py
Normal file
@@ -0,0 +1,246 @@
|
||||
import glob
|
||||
import random
|
||||
from typing import List
|
||||
from PIL import ImageFont
|
||||
from loguru import logger
|
||||
from moviepy.editor import *
|
||||
from moviepy.video.fx.crop import crop
|
||||
from moviepy.video.tools.subtitles import SubtitlesClip
|
||||
|
||||
from app.models.schema import VideoAspect
|
||||
from app.utils import utils
|
||||
|
||||
|
||||
def get_bgm_file(bgm_name: str = "random"):
|
||||
if not bgm_name:
|
||||
return ""
|
||||
if bgm_name == "random":
|
||||
suffix = "*.mp3"
|
||||
song_dir = utils.song_dir()
|
||||
# 使用glob.glob获取指定扩展名的文件列表
|
||||
files = glob.glob(os.path.join(song_dir, suffix))
|
||||
# 使用random.choice从列表中随机选择一个文件
|
||||
return random.choice(files)
|
||||
|
||||
file = os.path.join(utils.song_dir(), bgm_name)
|
||||
if os.path.exists(file):
|
||||
return file
|
||||
return ""
|
||||
|
||||
|
||||
def combine_videos(combined_video_path: str,
|
||||
video_paths: List[str],
|
||||
audio_file: str,
|
||||
video_aspect: VideoAspect = VideoAspect.portrait,
|
||||
max_clip_duration: int = 5,
|
||||
threads: int = 2,
|
||||
) -> str:
|
||||
logger.info(f"combining {len(video_paths)} videos into one file: {combined_video_path}")
|
||||
audio_clip = AudioFileClip(audio_file)
|
||||
max_duration = audio_clip.duration
|
||||
logger.info(f"max duration of audio: {max_duration} seconds")
|
||||
# Required duration of each clip
|
||||
req_dur = max_duration / len(video_paths)
|
||||
logger.info(f"each clip will be maximum {req_dur} seconds long")
|
||||
|
||||
aspect = VideoAspect(video_aspect)
|
||||
video_width, video_height = aspect.to_resolution()
|
||||
|
||||
clips = []
|
||||
tot_dur = 0
|
||||
# Add downloaded clips over and over until the duration of the audio (max_duration) has been reached
|
||||
while tot_dur < max_duration:
|
||||
for video_path in video_paths:
|
||||
clip = VideoFileClip(video_path)
|
||||
clip = clip.without_audio()
|
||||
# Check if clip is longer than the remaining audio
|
||||
if (max_duration - tot_dur) < clip.duration:
|
||||
clip = clip.subclip(0, (max_duration - tot_dur))
|
||||
# Only shorten clips if the calculated clip length (req_dur) is shorter than the actual clip to prevent still image
|
||||
elif req_dur < clip.duration:
|
||||
clip = clip.subclip(0, req_dur)
|
||||
clip = clip.set_fps(30)
|
||||
|
||||
# Not all videos are same size, so we need to resize them
|
||||
# logger.info(f"{video_path}: size is {clip.w} x {clip.h}, expected {video_width} x {video_height}")
|
||||
if clip.w != video_width or clip.h != video_height:
|
||||
if round((clip.w / clip.h), 4) < 0.5625:
|
||||
clip = crop(clip,
|
||||
width=clip.w,
|
||||
height=round(clip.w / 0.5625),
|
||||
x_center=clip.w / 2,
|
||||
y_center=clip.h / 2
|
||||
)
|
||||
else:
|
||||
clip = crop(clip,
|
||||
width=round(0.5625 * clip.h),
|
||||
height=clip.h,
|
||||
x_center=clip.w / 2,
|
||||
y_center=clip.h / 2
|
||||
)
|
||||
logger.info(f"resizing video to {video_width} x {video_height}")
|
||||
clip = clip.resize((video_width, video_height))
|
||||
|
||||
if clip.duration > max_clip_duration:
|
||||
clip = clip.subclip(0, max_clip_duration)
|
||||
|
||||
clips.append(clip)
|
||||
tot_dur += clip.duration
|
||||
|
||||
final_clip = concatenate_videoclips(clips)
|
||||
final_clip = final_clip.set_fps(30)
|
||||
logger.info(f"writing")
|
||||
final_clip.write_videofile(combined_video_path, threads=threads)
|
||||
logger.success(f"completed")
|
||||
return combined_video_path
|
||||
|
||||
|
||||
def wrap_text(text, max_width, font='Arial', fontsize=60):
|
||||
# 创建字体对象
|
||||
font = ImageFont.truetype(font, fontsize)
|
||||
|
||||
def get_text_size(inner_text):
|
||||
left, top, right, bottom = font.getbbox(inner_text)
|
||||
return right - left, bottom - top
|
||||
|
||||
width, height = get_text_size(text)
|
||||
if width <= max_width:
|
||||
return text
|
||||
|
||||
logger.warning(f"wrapping text, max_width: {max_width}, text_width: {width}, text: {text}")
|
||||
_wrapped_lines_ = []
|
||||
# 使用textwrap尝试分行,然后检查每行是否符合宽度限制
|
||||
|
||||
chars = list(text)
|
||||
_txt_ = ''
|
||||
for char in chars:
|
||||
_txt_ += char
|
||||
_width, _height = get_text_size(_txt_)
|
||||
if _width <= max_width:
|
||||
continue
|
||||
else:
|
||||
_wrapped_lines_.append(_txt_)
|
||||
_txt_ = ''
|
||||
_wrapped_lines_.append(_txt_)
|
||||
return '\n'.join(_wrapped_lines_)
|
||||
|
||||
|
||||
def generate_video(video_path: str,
|
||||
audio_path: str,
|
||||
subtitle_path: str,
|
||||
output_file: str,
|
||||
video_aspect: VideoAspect = VideoAspect.portrait,
|
||||
|
||||
threads: int = 2,
|
||||
|
||||
font_name: str = "",
|
||||
fontsize: int = 60,
|
||||
stroke_color: str = "#000000",
|
||||
stroke_width: float = 1.5,
|
||||
text_fore_color: str = "white",
|
||||
text_background_color: str = "transparent",
|
||||
|
||||
bgm_file: str = "",
|
||||
):
|
||||
aspect = VideoAspect(video_aspect)
|
||||
video_width, video_height = aspect.to_resolution()
|
||||
|
||||
logger.info(f"start, video size: {video_width} x {video_height}")
|
||||
logger.info(f" ① video: {video_path}")
|
||||
logger.info(f" ② audio: {audio_path}")
|
||||
logger.info(f" ③ subtitle: {subtitle_path}")
|
||||
logger.info(f" ④ output: {output_file}")
|
||||
|
||||
if not font_name:
|
||||
font_name = "STHeitiMedium.ttc"
|
||||
font_path = os.path.join(utils.font_dir(), font_name)
|
||||
logger.info(f"using font: {font_path}")
|
||||
|
||||
# 自定义的生成器函数,包含换行逻辑
|
||||
def generator(txt):
|
||||
# 应用自动换行
|
||||
wrapped_txt = wrap_text(txt, max_width=video_width - 100,
|
||||
font=font_path,
|
||||
fontsize=fontsize) # 调整max_width以适应你的视频
|
||||
return TextClip(
|
||||
wrapped_txt,
|
||||
font=font_path,
|
||||
fontsize=fontsize,
|
||||
color=text_fore_color,
|
||||
bg_color=text_background_color,
|
||||
stroke_color=stroke_color,
|
||||
stroke_width=stroke_width,
|
||||
print_cmd=False,
|
||||
)
|
||||
|
||||
position_height = video_height - 200
|
||||
if video_aspect == VideoAspect.landscape:
|
||||
position_height = video_height - 100
|
||||
|
||||
clips = [
|
||||
VideoFileClip(video_path),
|
||||
# subtitles.set_position(lambda _t: ('center', position_height))
|
||||
]
|
||||
# Burn the subtitles into the video
|
||||
if subtitle_path and os.path.exists(subtitle_path):
|
||||
subtitles = SubtitlesClip(subtitle_path, generator)
|
||||
clips.append(subtitles.set_position(lambda _t: ('center', position_height)))
|
||||
|
||||
result = CompositeVideoClip(clips)
|
||||
|
||||
# Add the audio
|
||||
audio = AudioFileClip(audio_path)
|
||||
result = result.set_audio(audio)
|
||||
|
||||
temp_output_file = f"{output_file}.temp.mp4"
|
||||
logger.info(f"writing to temp file: {temp_output_file}")
|
||||
result.write_videofile(temp_output_file, threads=threads or 2)
|
||||
|
||||
video_clip = VideoFileClip(temp_output_file)
|
||||
if bgm_file:
|
||||
logger.info(f"adding background music: {bgm_file}")
|
||||
# Add song to video at 30% volume using moviepy
|
||||
original_duration = video_clip.duration
|
||||
original_audio = video_clip.audio
|
||||
song_clip = AudioFileClip(bgm_file).set_fps(44100)
|
||||
# Set the volume of the song to 10% of the original volume
|
||||
song_clip = song_clip.volumex(0.2).set_fps(44100)
|
||||
# Add the song to the video
|
||||
comp_audio = CompositeAudioClip([original_audio, song_clip])
|
||||
video_clip = video_clip.set_audio(comp_audio)
|
||||
video_clip = video_clip.set_fps(30)
|
||||
video_clip = video_clip.set_duration(original_duration)
|
||||
# 编码为aac,否则iPhone里面无法播放
|
||||
logger.info(f"encoding audio codec to aac")
|
||||
video_clip.write_videofile(output_file, audio_codec="aac", threads=threads)
|
||||
# delete the temp file
|
||||
os.remove(temp_output_file)
|
||||
logger.success(f"completed")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
txt = "hello 幸福经常被描述为最终人生目标和人类追求的核心 但它通常涉及对个人生活中意义和目的的深刻感悟"
|
||||
font = utils.resource_dir() + "/fonts/STHeitiMedium.ttc"
|
||||
t = wrap_text(text=txt, max_width=1000, font=font, fontsize=60)
|
||||
print(t)
|
||||
|
||||
task_id = "c12fd1e6-4b0a-4d65-a075-c87abe35a072"
|
||||
task_dir = utils.task_dir(task_id)
|
||||
video_file = f"{task_dir}/combined.mp4"
|
||||
audio_file = f"{task_dir}/audio.mp3"
|
||||
subtitle_file = f"{task_dir}/subtitle.srt"
|
||||
output_file = f"{task_dir}/final.mp4"
|
||||
generate_video(video_path=video_file,
|
||||
audio_path=audio_file,
|
||||
subtitle_path=subtitle_file,
|
||||
output_file=output_file,
|
||||
video_aspect=VideoAspect.portrait,
|
||||
threads=2,
|
||||
font_name="STHeitiMedium.ttc",
|
||||
fontsize=60,
|
||||
stroke_color="#000000",
|
||||
stroke_width=1.5,
|
||||
text_fore_color="white",
|
||||
text_background_color="transparent",
|
||||
bgm_file=""
|
||||
)
|
||||
101
app/services/voice.py
Normal file
101
app/services/voice.py
Normal file
@@ -0,0 +1,101 @@
|
||||
import asyncio
|
||||
from xml.sax.saxutils import unescape
|
||||
from edge_tts.submaker import mktimestamp
|
||||
from loguru import logger
|
||||
from edge_tts import submaker, SubMaker
|
||||
import edge_tts
|
||||
from app.utils import utils
|
||||
|
||||
|
||||
def tts(text: str, voice_name: str, voice_file: str) -> SubMaker:
|
||||
logger.info(f"start, voice name: {voice_name}")
|
||||
|
||||
async def _do() -> SubMaker:
|
||||
communicate = edge_tts.Communicate(text, voice_name)
|
||||
sub_maker = edge_tts.SubMaker()
|
||||
with open(voice_file, "wb") as file:
|
||||
async for chunk in communicate.stream():
|
||||
if chunk["type"] == "audio":
|
||||
file.write(chunk["data"])
|
||||
elif chunk["type"] == "WordBoundary":
|
||||
sub_maker.create_sub((chunk["offset"], chunk["duration"]), chunk["text"])
|
||||
return sub_maker
|
||||
|
||||
sub_maker = asyncio.run(_do())
|
||||
logger.info(f"completed, output file: {voice_file}")
|
||||
return sub_maker
|
||||
|
||||
|
||||
def create_subtitle(sub_maker: submaker.SubMaker, text: str, subtitle_file: str):
|
||||
"""
|
||||
优化字幕文件
|
||||
1. 将字幕文件按照标点符号分割成多行
|
||||
2. 逐行匹配字幕文件中的文本
|
||||
3. 生成新的字幕文件
|
||||
"""
|
||||
|
||||
def formatter(idx: int, start_time: float, end_time: float, sub_text: str) -> str:
|
||||
"""
|
||||
1
|
||||
00:00:00,000 --> 00:00:02,360
|
||||
跑步是一项简单易行的运动
|
||||
"""
|
||||
start_t = mktimestamp(start_time).replace(".", ",")
|
||||
end_t = mktimestamp(end_time).replace(".", ",")
|
||||
return (
|
||||
f"{idx}\n"
|
||||
f"{start_t} --> {end_t}\n"
|
||||
f"{sub_text}\n"
|
||||
)
|
||||
|
||||
start_time = -1.0
|
||||
sub_items = []
|
||||
sub_index = 0
|
||||
|
||||
script_lines = utils.split_string_by_punctuations(text)
|
||||
|
||||
sub_line = ""
|
||||
for _, (offset, sub) in enumerate(zip(sub_maker.offset, sub_maker.subs)):
|
||||
_start_time, end_time = offset
|
||||
if start_time < 0:
|
||||
start_time = _start_time
|
||||
|
||||
sub = unescape(sub)
|
||||
sub_line += sub
|
||||
if sub_line == script_lines[sub_index]:
|
||||
sub_index += 1
|
||||
sub_items.append(formatter(
|
||||
idx=sub_index,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
sub_text=sub_line,
|
||||
))
|
||||
start_time = -1.0
|
||||
sub_line = ""
|
||||
|
||||
with open(subtitle_file, "w", encoding="utf-8") as file:
|
||||
file.write("\n".join(sub_items))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
temp_dir = utils.storage_dir("temp")
|
||||
|
||||
voice_names = [
|
||||
# 女性
|
||||
"zh-CN-XiaoxiaoNeural",
|
||||
"zh-CN-XiaoyiNeural",
|
||||
# 男性
|
||||
"zh-CN-YunyangNeural",
|
||||
"zh-CN-YunxiNeural",
|
||||
]
|
||||
text = """
|
||||
预计未来3天深圳冷空气活动频繁,未来两天持续阴天有小雨,出门带好雨具;
|
||||
10-11日持续阴天有小雨,日温差小,气温在13-17℃之间,体感阴凉;
|
||||
12日天气短暂好转,早晚清凉;
|
||||
"""
|
||||
|
||||
for voice_name in voice_names:
|
||||
voice_file = f"{temp_dir}/tts-{voice_name}.mp3"
|
||||
subtitle_file = f"{temp_dir}/tts.mp3.srt"
|
||||
sub_maker = tts(text=text, voice_name=voice_name, voice_file=voice_file)
|
||||
create_subtitle(sub_maker=sub_maker, text=text, subtitle_file=subtitle_file)
|
||||
167
app/utils/utils.py
Normal file
167
app/utils/utils.py
Normal file
@@ -0,0 +1,167 @@
|
||||
import os
|
||||
import threading
|
||||
from typing import Any
|
||||
from loguru import logger
|
||||
import json
|
||||
from uuid import uuid4
|
||||
import urllib3
|
||||
|
||||
from app.models import const
|
||||
|
||||
urllib3.disable_warnings()
|
||||
|
||||
|
||||
def get_response(status: int, data: Any = None, message: str = ""):
|
||||
obj = {
|
||||
'status': status,
|
||||
}
|
||||
if data:
|
||||
obj['data'] = data
|
||||
if message:
|
||||
obj['message'] = message
|
||||
return obj
|
||||
|
||||
|
||||
def to_json(obj):
|
||||
# 定义一个辅助函数来处理不同类型的对象
|
||||
def serialize(o):
|
||||
# 如果对象是可序列化类型,直接返回
|
||||
if isinstance(o, (int, float, bool, str)) or o is None:
|
||||
return o
|
||||
# 如果对象是二进制数据,转换为base64编码的字符串
|
||||
elif isinstance(o, bytes):
|
||||
return "*** binary data ***"
|
||||
# 如果对象是字典,递归处理每个键值对
|
||||
elif isinstance(o, dict):
|
||||
return {k: serialize(v) for k, v in o.items()}
|
||||
# 如果对象是列表或元组,递归处理每个元素
|
||||
elif isinstance(o, (list, tuple)):
|
||||
return [serialize(item) for item in o]
|
||||
# 如果对象是自定义类型,尝试返回其__dict__属性
|
||||
elif hasattr(o, '__dict__'):
|
||||
return serialize(o.__dict__)
|
||||
# 其他情况返回None(或者可以选择抛出异常)
|
||||
else:
|
||||
return None
|
||||
|
||||
# 使用serialize函数处理输入对象
|
||||
serialized_obj = serialize(obj)
|
||||
|
||||
# 序列化处理后的对象为JSON字符串
|
||||
return json.dumps(serialized_obj, ensure_ascii=False, indent=4)
|
||||
|
||||
|
||||
def get_uuid(remove_hyphen: bool = False):
|
||||
u = str(uuid4())
|
||||
if remove_hyphen:
|
||||
u = u.replace("-", "")
|
||||
return u
|
||||
|
||||
|
||||
def root_dir():
|
||||
return os.path.dirname(os.path.dirname(os.path.dirname(os.path.realpath(__file__))))
|
||||
|
||||
|
||||
def storage_dir(sub_dir: str = ""):
|
||||
d = os.path.join(root_dir(), "storage")
|
||||
if sub_dir:
|
||||
d = os.path.join(d, sub_dir)
|
||||
return d
|
||||
|
||||
|
||||
def resource_dir(sub_dir: str = ""):
|
||||
d = os.path.join(root_dir(), "resource")
|
||||
if sub_dir:
|
||||
d = os.path.join(d, sub_dir)
|
||||
return d
|
||||
|
||||
|
||||
def task_dir(sub_dir: str = ""):
|
||||
d = os.path.join(storage_dir(), "tasks")
|
||||
if sub_dir:
|
||||
d = os.path.join(d, sub_dir)
|
||||
if not os.path.exists(d):
|
||||
os.makedirs(d)
|
||||
return d
|
||||
|
||||
|
||||
def font_dir(sub_dir: str = ""):
|
||||
d = resource_dir(f"fonts")
|
||||
if sub_dir:
|
||||
d = os.path.join(d, sub_dir)
|
||||
if not os.path.exists(d):
|
||||
os.makedirs(d)
|
||||
return d
|
||||
|
||||
|
||||
def song_dir(sub_dir: str = ""):
|
||||
d = resource_dir(f"songs")
|
||||
if sub_dir:
|
||||
d = os.path.join(d, sub_dir)
|
||||
if not os.path.exists(d):
|
||||
os.makedirs(d)
|
||||
return d
|
||||
|
||||
|
||||
def public_dir(sub_dir: str = ""):
|
||||
d = resource_dir(f"public")
|
||||
if sub_dir:
|
||||
d = os.path.join(d, sub_dir)
|
||||
if not os.path.exists(d):
|
||||
os.makedirs(d)
|
||||
return d
|
||||
|
||||
|
||||
def run_in_background(func, *args, **kwargs):
|
||||
def run():
|
||||
try:
|
||||
func(*args, **kwargs)
|
||||
except Exception as e:
|
||||
logger.error(f"run_in_background error: {e}")
|
||||
|
||||
thread = threading.Thread(target=run)
|
||||
thread.start()
|
||||
return thread
|
||||
|
||||
|
||||
def time_convert_seconds_to_hmsm(seconds) -> str:
|
||||
hours = int(seconds // 3600)
|
||||
seconds = seconds % 3600
|
||||
minutes = int(seconds // 60)
|
||||
milliseconds = int(seconds * 1000) % 1000
|
||||
seconds = int(seconds % 60)
|
||||
return "{:02d}:{:02d}:{:02d},{:03d}".format(hours, minutes, seconds, milliseconds)
|
||||
|
||||
|
||||
def text_to_srt(idx: int, msg: str, start_time: float, end_time: float) -> str:
|
||||
start_time = time_convert_seconds_to_hmsm(start_time)
|
||||
end_time = time_convert_seconds_to_hmsm(end_time)
|
||||
srt = """%d
|
||||
%s --> %s
|
||||
%s
|
||||
""" % (
|
||||
idx,
|
||||
start_time,
|
||||
end_time,
|
||||
msg,
|
||||
)
|
||||
return srt
|
||||
|
||||
|
||||
def str_contains_punctuation(word):
|
||||
for p in const.punctuations:
|
||||
if p in word:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def split_string_by_punctuations(s):
|
||||
result = []
|
||||
txt = ""
|
||||
for char in s:
|
||||
if char not in const.punctuations:
|
||||
txt += char
|
||||
else:
|
||||
result.append(txt.strip())
|
||||
txt = ""
|
||||
return result
|
||||
33
config.example.toml
Normal file
33
config.example.toml
Normal file
@@ -0,0 +1,33 @@
|
||||
[app]
|
||||
# Pexels API Key
|
||||
# Register at https://www.pexels.com/api/ to get your API key.
|
||||
# You can use multiple keys to avoid rate limits.
|
||||
pexels_api_keys = []
|
||||
|
||||
# OpenAI API Key
|
||||
# Visit https://openai.com/api/ for details on obtaining an API key.
|
||||
openai_api_key = ""
|
||||
openai_base_url=""
|
||||
openai_model_name = "gpt-4-turbo-preview"
|
||||
|
||||
# Subtitle Provider, "edge" or "whisper"
|
||||
# If empty, the subtitle will not be generated
|
||||
subtitle_provider = "edge"
|
||||
|
||||
[whisper]
|
||||
# Only effective when subtitle_provider is "whisper"
|
||||
|
||||
# Run on GPU with FP16
|
||||
# model = WhisperModel(model_size, device="cuda", compute_type="float16")
|
||||
|
||||
# Run on GPU with INT8
|
||||
# model = WhisperModel(model_size, device="cuda", compute_type="int8_float16")
|
||||
|
||||
# Run on CPU with INT8
|
||||
# model = WhisperModel(model_size, device="cpu", compute_type="int8")
|
||||
|
||||
# recommended model_size: "large-v3"
|
||||
model_size="large-v3"
|
||||
# if you want to use GPU, set device="cuda"
|
||||
device="CPU"
|
||||
compute_type="int8"
|
||||
BIN
docs/api.jpg
Normal file
BIN
docs/api.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 269 KiB |
BIN
docs/demo-landscape.jpg
Normal file
BIN
docs/demo-landscape.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 949 KiB |
BIN
docs/demo-landscape.mp4
Normal file
BIN
docs/demo-landscape.mp4
Normal file
Binary file not shown.
BIN
docs/demo-portrait-1.jpg
Normal file
BIN
docs/demo-portrait-1.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 428 KiB |
BIN
docs/demo-portrait-1.mp4
Normal file
BIN
docs/demo-portrait-1.mp4
Normal file
Binary file not shown.
BIN
docs/demo-portrait-2.jpg
Normal file
BIN
docs/demo-portrait-2.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 495 KiB |
BIN
docs/demo-portrait-2.mp4
Normal file
BIN
docs/demo-portrait-2.mp4
Normal file
Binary file not shown.
BIN
docs/douyin.jpg
Normal file
BIN
docs/douyin.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 172 KiB |
941
docs/voice-list.txt
Normal file
941
docs/voice-list.txt
Normal file
@@ -0,0 +1,941 @@
|
||||
Name: af-ZA-AdriNeural
|
||||
Gender: Female
|
||||
|
||||
Name: af-ZA-WillemNeural
|
||||
Gender: Male
|
||||
|
||||
Name: am-ET-AmehaNeural
|
||||
Gender: Male
|
||||
|
||||
Name: am-ET-MekdesNeural
|
||||
Gender: Female
|
||||
|
||||
Name: ar-AE-FatimaNeural
|
||||
Gender: Female
|
||||
|
||||
Name: ar-AE-HamdanNeural
|
||||
Gender: Male
|
||||
|
||||
Name: ar-BH-AliNeural
|
||||
Gender: Male
|
||||
|
||||
Name: ar-BH-LailaNeural
|
||||
Gender: Female
|
||||
|
||||
Name: ar-DZ-AminaNeural
|
||||
Gender: Female
|
||||
|
||||
Name: ar-DZ-IsmaelNeural
|
||||
Gender: Male
|
||||
|
||||
Name: ar-EG-SalmaNeural
|
||||
Gender: Female
|
||||
|
||||
Name: ar-EG-ShakirNeural
|
||||
Gender: Male
|
||||
|
||||
Name: ar-IQ-BasselNeural
|
||||
Gender: Male
|
||||
|
||||
Name: ar-IQ-RanaNeural
|
||||
Gender: Female
|
||||
|
||||
Name: ar-JO-SanaNeural
|
||||
Gender: Female
|
||||
|
||||
Name: ar-JO-TaimNeural
|
||||
Gender: Male
|
||||
|
||||
Name: ar-KW-FahedNeural
|
||||
Gender: Male
|
||||
|
||||
Name: ar-KW-NouraNeural
|
||||
Gender: Female
|
||||
|
||||
Name: ar-LB-LaylaNeural
|
||||
Gender: Female
|
||||
|
||||
Name: ar-LB-RamiNeural
|
||||
Gender: Male
|
||||
|
||||
Name: ar-LY-ImanNeural
|
||||
Gender: Female
|
||||
|
||||
Name: ar-LY-OmarNeural
|
||||
Gender: Male
|
||||
|
||||
Name: ar-MA-JamalNeural
|
||||
Gender: Male
|
||||
|
||||
Name: ar-MA-MounaNeural
|
||||
Gender: Female
|
||||
|
||||
Name: ar-OM-AbdullahNeural
|
||||
Gender: Male
|
||||
|
||||
Name: ar-OM-AyshaNeural
|
||||
Gender: Female
|
||||
|
||||
Name: ar-QA-AmalNeural
|
||||
Gender: Female
|
||||
|
||||
Name: ar-QA-MoazNeural
|
||||
Gender: Male
|
||||
|
||||
Name: ar-SA-HamedNeural
|
||||
Gender: Male
|
||||
|
||||
Name: ar-SA-ZariyahNeural
|
||||
Gender: Female
|
||||
|
||||
Name: ar-SY-AmanyNeural
|
||||
Gender: Female
|
||||
|
||||
Name: ar-SY-LaithNeural
|
||||
Gender: Male
|
||||
|
||||
Name: ar-TN-HediNeural
|
||||
Gender: Male
|
||||
|
||||
Name: ar-TN-ReemNeural
|
||||
Gender: Female
|
||||
|
||||
Name: ar-YE-MaryamNeural
|
||||
Gender: Female
|
||||
|
||||
Name: ar-YE-SalehNeural
|
||||
Gender: Male
|
||||
|
||||
Name: az-AZ-BabekNeural
|
||||
Gender: Male
|
||||
|
||||
Name: az-AZ-BanuNeural
|
||||
Gender: Female
|
||||
|
||||
Name: bg-BG-BorislavNeural
|
||||
Gender: Male
|
||||
|
||||
Name: bg-BG-KalinaNeural
|
||||
Gender: Female
|
||||
|
||||
Name: bn-BD-NabanitaNeural
|
||||
Gender: Female
|
||||
|
||||
Name: bn-BD-PradeepNeural
|
||||
Gender: Male
|
||||
|
||||
Name: bn-IN-BashkarNeural
|
||||
Gender: Male
|
||||
|
||||
Name: bn-IN-TanishaaNeural
|
||||
Gender: Female
|
||||
|
||||
Name: bs-BA-GoranNeural
|
||||
Gender: Male
|
||||
|
||||
Name: bs-BA-VesnaNeural
|
||||
Gender: Female
|
||||
|
||||
Name: ca-ES-EnricNeural
|
||||
Gender: Male
|
||||
|
||||
Name: ca-ES-JoanaNeural
|
||||
Gender: Female
|
||||
|
||||
Name: cs-CZ-AntoninNeural
|
||||
Gender: Male
|
||||
|
||||
Name: cs-CZ-VlastaNeural
|
||||
Gender: Female
|
||||
|
||||
Name: cy-GB-AledNeural
|
||||
Gender: Male
|
||||
|
||||
Name: cy-GB-NiaNeural
|
||||
Gender: Female
|
||||
|
||||
Name: da-DK-ChristelNeural
|
||||
Gender: Female
|
||||
|
||||
Name: da-DK-JeppeNeural
|
||||
Gender: Male
|
||||
|
||||
Name: de-AT-IngridNeural
|
||||
Gender: Female
|
||||
|
||||
Name: de-AT-JonasNeural
|
||||
Gender: Male
|
||||
|
||||
Name: de-CH-JanNeural
|
||||
Gender: Male
|
||||
|
||||
Name: de-CH-LeniNeural
|
||||
Gender: Female
|
||||
|
||||
Name: de-DE-AmalaNeural
|
||||
Gender: Female
|
||||
|
||||
Name: de-DE-ConradNeural
|
||||
Gender: Male
|
||||
|
||||
Name: de-DE-FlorianMultilingualNeural
|
||||
Gender: Male
|
||||
|
||||
Name: de-DE-KatjaNeural
|
||||
Gender: Female
|
||||
|
||||
Name: de-DE-KillianNeural
|
||||
Gender: Male
|
||||
|
||||
Name: de-DE-SeraphinaMultilingualNeural
|
||||
Gender: Female
|
||||
|
||||
Name: el-GR-AthinaNeural
|
||||
Gender: Female
|
||||
|
||||
Name: el-GR-NestorasNeural
|
||||
Gender: Male
|
||||
|
||||
Name: en-AU-NatashaNeural
|
||||
Gender: Female
|
||||
|
||||
Name: en-AU-WilliamNeural
|
||||
Gender: Male
|
||||
|
||||
Name: en-CA-ClaraNeural
|
||||
Gender: Female
|
||||
|
||||
Name: en-CA-LiamNeural
|
||||
Gender: Male
|
||||
|
||||
Name: en-GB-LibbyNeural
|
||||
Gender: Female
|
||||
|
||||
Name: en-GB-MaisieNeural
|
||||
Gender: Female
|
||||
|
||||
Name: en-GB-RyanNeural
|
||||
Gender: Male
|
||||
|
||||
Name: en-GB-SoniaNeural
|
||||
Gender: Female
|
||||
|
||||
Name: en-GB-ThomasNeural
|
||||
Gender: Male
|
||||
|
||||
Name: en-HK-SamNeural
|
||||
Gender: Male
|
||||
|
||||
Name: en-HK-YanNeural
|
||||
Gender: Female
|
||||
|
||||
Name: en-IE-ConnorNeural
|
||||
Gender: Male
|
||||
|
||||
Name: en-IE-EmilyNeural
|
||||
Gender: Female
|
||||
|
||||
Name: en-IN-NeerjaExpressiveNeural
|
||||
Gender: Female
|
||||
|
||||
Name: en-IN-NeerjaNeural
|
||||
Gender: Female
|
||||
|
||||
Name: en-IN-PrabhatNeural
|
||||
Gender: Male
|
||||
|
||||
Name: en-KE-AsiliaNeural
|
||||
Gender: Female
|
||||
|
||||
Name: en-KE-ChilembaNeural
|
||||
Gender: Male
|
||||
|
||||
Name: en-NG-AbeoNeural
|
||||
Gender: Male
|
||||
|
||||
Name: en-NG-EzinneNeural
|
||||
Gender: Female
|
||||
|
||||
Name: en-NZ-MitchellNeural
|
||||
Gender: Male
|
||||
|
||||
Name: en-NZ-MollyNeural
|
||||
Gender: Female
|
||||
|
||||
Name: en-PH-JamesNeural
|
||||
Gender: Male
|
||||
|
||||
Name: en-PH-RosaNeural
|
||||
Gender: Female
|
||||
|
||||
Name: en-SG-LunaNeural
|
||||
Gender: Female
|
||||
|
||||
Name: en-SG-WayneNeural
|
||||
Gender: Male
|
||||
|
||||
Name: en-TZ-ElimuNeural
|
||||
Gender: Male
|
||||
|
||||
Name: en-TZ-ImaniNeural
|
||||
Gender: Female
|
||||
|
||||
Name: en-US-AnaNeural
|
||||
Gender: Female
|
||||
|
||||
Name: en-US-AndrewNeural
|
||||
Gender: Male
|
||||
|
||||
Name: en-US-AriaNeural
|
||||
Gender: Female
|
||||
|
||||
Name: en-US-AvaNeural
|
||||
Gender: Female
|
||||
|
||||
Name: en-US-BrianNeural
|
||||
Gender: Male
|
||||
|
||||
Name: en-US-ChristopherNeural
|
||||
Gender: Male
|
||||
|
||||
Name: en-US-EmmaNeural
|
||||
Gender: Female
|
||||
|
||||
Name: en-US-EricNeural
|
||||
Gender: Male
|
||||
|
||||
Name: en-US-GuyNeural
|
||||
Gender: Male
|
||||
|
||||
Name: en-US-JennyNeural
|
||||
Gender: Female
|
||||
|
||||
Name: en-US-MichelleNeural
|
||||
Gender: Female
|
||||
|
||||
Name: en-US-RogerNeural
|
||||
Gender: Male
|
||||
|
||||
Name: en-US-SteffanNeural
|
||||
Gender: Male
|
||||
|
||||
Name: en-ZA-LeahNeural
|
||||
Gender: Female
|
||||
|
||||
Name: en-ZA-LukeNeural
|
||||
Gender: Male
|
||||
|
||||
Name: es-AR-ElenaNeural
|
||||
Gender: Female
|
||||
|
||||
Name: es-AR-TomasNeural
|
||||
Gender: Male
|
||||
|
||||
Name: es-BO-MarceloNeural
|
||||
Gender: Male
|
||||
|
||||
Name: es-BO-SofiaNeural
|
||||
Gender: Female
|
||||
|
||||
Name: es-CL-CatalinaNeural
|
||||
Gender: Female
|
||||
|
||||
Name: es-CL-LorenzoNeural
|
||||
Gender: Male
|
||||
|
||||
Name: es-CO-GonzaloNeural
|
||||
Gender: Male
|
||||
|
||||
Name: es-CO-SalomeNeural
|
||||
Gender: Female
|
||||
|
||||
Name: es-CR-JuanNeural
|
||||
Gender: Male
|
||||
|
||||
Name: es-CR-MariaNeural
|
||||
Gender: Female
|
||||
|
||||
Name: es-CU-BelkysNeural
|
||||
Gender: Female
|
||||
|
||||
Name: es-CU-ManuelNeural
|
||||
Gender: Male
|
||||
|
||||
Name: es-DO-EmilioNeural
|
||||
Gender: Male
|
||||
|
||||
Name: es-DO-RamonaNeural
|
||||
Gender: Female
|
||||
|
||||
Name: es-EC-AndreaNeural
|
||||
Gender: Female
|
||||
|
||||
Name: es-EC-LuisNeural
|
||||
Gender: Male
|
||||
|
||||
Name: es-ES-AlvaroNeural
|
||||
Gender: Male
|
||||
|
||||
Name: es-ES-ElviraNeural
|
||||
Gender: Female
|
||||
|
||||
Name: es-ES-XimenaNeural
|
||||
Gender: Female
|
||||
|
||||
Name: es-GQ-JavierNeural
|
||||
Gender: Male
|
||||
|
||||
Name: es-GQ-TeresaNeural
|
||||
Gender: Female
|
||||
|
||||
Name: es-GT-AndresNeural
|
||||
Gender: Male
|
||||
|
||||
Name: es-GT-MartaNeural
|
||||
Gender: Female
|
||||
|
||||
Name: es-HN-CarlosNeural
|
||||
Gender: Male
|
||||
|
||||
Name: es-HN-KarlaNeural
|
||||
Gender: Female
|
||||
|
||||
Name: es-MX-DaliaNeural
|
||||
Gender: Female
|
||||
|
||||
Name: es-MX-JorgeNeural
|
||||
Gender: Male
|
||||
|
||||
Name: es-NI-FedericoNeural
|
||||
Gender: Male
|
||||
|
||||
Name: es-NI-YolandaNeural
|
||||
Gender: Female
|
||||
|
||||
Name: es-PA-MargaritaNeural
|
||||
Gender: Female
|
||||
|
||||
Name: es-PA-RobertoNeural
|
||||
Gender: Male
|
||||
|
||||
Name: es-PE-AlexNeural
|
||||
Gender: Male
|
||||
|
||||
Name: es-PE-CamilaNeural
|
||||
Gender: Female
|
||||
|
||||
Name: es-PR-KarinaNeural
|
||||
Gender: Female
|
||||
|
||||
Name: es-PR-VictorNeural
|
||||
Gender: Male
|
||||
|
||||
Name: es-PY-MarioNeural
|
||||
Gender: Male
|
||||
|
||||
Name: es-PY-TaniaNeural
|
||||
Gender: Female
|
||||
|
||||
Name: es-SV-LorenaNeural
|
||||
Gender: Female
|
||||
|
||||
Name: es-SV-RodrigoNeural
|
||||
Gender: Male
|
||||
|
||||
Name: es-US-AlonsoNeural
|
||||
Gender: Male
|
||||
|
||||
Name: es-US-PalomaNeural
|
||||
Gender: Female
|
||||
|
||||
Name: es-UY-MateoNeural
|
||||
Gender: Male
|
||||
|
||||
Name: es-UY-ValentinaNeural
|
||||
Gender: Female
|
||||
|
||||
Name: es-VE-PaolaNeural
|
||||
Gender: Female
|
||||
|
||||
Name: es-VE-SebastianNeural
|
||||
Gender: Male
|
||||
|
||||
Name: et-EE-AnuNeural
|
||||
Gender: Female
|
||||
|
||||
Name: et-EE-KertNeural
|
||||
Gender: Male
|
||||
|
||||
Name: fa-IR-DilaraNeural
|
||||
Gender: Female
|
||||
|
||||
Name: fa-IR-FaridNeural
|
||||
Gender: Male
|
||||
|
||||
Name: fi-FI-HarriNeural
|
||||
Gender: Male
|
||||
|
||||
Name: fi-FI-NooraNeural
|
||||
Gender: Female
|
||||
|
||||
Name: fil-PH-AngeloNeural
|
||||
Gender: Male
|
||||
|
||||
Name: fil-PH-BlessicaNeural
|
||||
Gender: Female
|
||||
|
||||
Name: fr-BE-CharlineNeural
|
||||
Gender: Female
|
||||
|
||||
Name: fr-BE-GerardNeural
|
||||
Gender: Male
|
||||
|
||||
Name: fr-CA-AntoineNeural
|
||||
Gender: Male
|
||||
|
||||
Name: fr-CA-JeanNeural
|
||||
Gender: Male
|
||||
|
||||
Name: fr-CA-SylvieNeural
|
||||
Gender: Female
|
||||
|
||||
Name: fr-CA-ThierryNeural
|
||||
Gender: Male
|
||||
|
||||
Name: fr-CH-ArianeNeural
|
||||
Gender: Female
|
||||
|
||||
Name: fr-CH-FabriceNeural
|
||||
Gender: Male
|
||||
|
||||
Name: fr-FR-DeniseNeural
|
||||
Gender: Female
|
||||
|
||||
Name: fr-FR-EloiseNeural
|
||||
Gender: Female
|
||||
|
||||
Name: fr-FR-HenriNeural
|
||||
Gender: Male
|
||||
|
||||
Name: fr-FR-RemyMultilingualNeural
|
||||
Gender: Male
|
||||
|
||||
Name: fr-FR-VivienneMultilingualNeural
|
||||
Gender: Female
|
||||
|
||||
Name: ga-IE-ColmNeural
|
||||
Gender: Male
|
||||
|
||||
Name: ga-IE-OrlaNeural
|
||||
Gender: Female
|
||||
|
||||
Name: gl-ES-RoiNeural
|
||||
Gender: Male
|
||||
|
||||
Name: gl-ES-SabelaNeural
|
||||
Gender: Female
|
||||
|
||||
Name: gu-IN-DhwaniNeural
|
||||
Gender: Female
|
||||
|
||||
Name: gu-IN-NiranjanNeural
|
||||
Gender: Male
|
||||
|
||||
Name: he-IL-AvriNeural
|
||||
Gender: Male
|
||||
|
||||
Name: he-IL-HilaNeural
|
||||
Gender: Female
|
||||
|
||||
Name: hi-IN-MadhurNeural
|
||||
Gender: Male
|
||||
|
||||
Name: hi-IN-SwaraNeural
|
||||
Gender: Female
|
||||
|
||||
Name: hr-HR-GabrijelaNeural
|
||||
Gender: Female
|
||||
|
||||
Name: hr-HR-SreckoNeural
|
||||
Gender: Male
|
||||
|
||||
Name: hu-HU-NoemiNeural
|
||||
Gender: Female
|
||||
|
||||
Name: hu-HU-TamasNeural
|
||||
Gender: Male
|
||||
|
||||
Name: id-ID-ArdiNeural
|
||||
Gender: Male
|
||||
|
||||
Name: id-ID-GadisNeural
|
||||
Gender: Female
|
||||
|
||||
Name: is-IS-GudrunNeural
|
||||
Gender: Female
|
||||
|
||||
Name: is-IS-GunnarNeural
|
||||
Gender: Male
|
||||
|
||||
Name: it-IT-DiegoNeural
|
||||
Gender: Male
|
||||
|
||||
Name: it-IT-ElsaNeural
|
||||
Gender: Female
|
||||
|
||||
Name: it-IT-GiuseppeNeural
|
||||
Gender: Male
|
||||
|
||||
Name: it-IT-IsabellaNeural
|
||||
Gender: Female
|
||||
|
||||
Name: ja-JP-KeitaNeural
|
||||
Gender: Male
|
||||
|
||||
Name: ja-JP-NanamiNeural
|
||||
Gender: Female
|
||||
|
||||
Name: jv-ID-DimasNeural
|
||||
Gender: Male
|
||||
|
||||
Name: jv-ID-SitiNeural
|
||||
Gender: Female
|
||||
|
||||
Name: ka-GE-EkaNeural
|
||||
Gender: Female
|
||||
|
||||
Name: ka-GE-GiorgiNeural
|
||||
Gender: Male
|
||||
|
||||
Name: kk-KZ-AigulNeural
|
||||
Gender: Female
|
||||
|
||||
Name: kk-KZ-DauletNeural
|
||||
Gender: Male
|
||||
|
||||
Name: km-KH-PisethNeural
|
||||
Gender: Male
|
||||
|
||||
Name: km-KH-SreymomNeural
|
||||
Gender: Female
|
||||
|
||||
Name: kn-IN-GaganNeural
|
||||
Gender: Male
|
||||
|
||||
Name: kn-IN-SapnaNeural
|
||||
Gender: Female
|
||||
|
||||
Name: ko-KR-HyunsuNeural
|
||||
Gender: Male
|
||||
|
||||
Name: ko-KR-InJoonNeural
|
||||
Gender: Male
|
||||
|
||||
Name: ko-KR-SunHiNeural
|
||||
Gender: Female
|
||||
|
||||
Name: lo-LA-ChanthavongNeural
|
||||
Gender: Male
|
||||
|
||||
Name: lo-LA-KeomanyNeural
|
||||
Gender: Female
|
||||
|
||||
Name: lt-LT-LeonasNeural
|
||||
Gender: Male
|
||||
|
||||
Name: lt-LT-OnaNeural
|
||||
Gender: Female
|
||||
|
||||
Name: lv-LV-EveritaNeural
|
||||
Gender: Female
|
||||
|
||||
Name: lv-LV-NilsNeural
|
||||
Gender: Male
|
||||
|
||||
Name: mk-MK-AleksandarNeural
|
||||
Gender: Male
|
||||
|
||||
Name: mk-MK-MarijaNeural
|
||||
Gender: Female
|
||||
|
||||
Name: ml-IN-MidhunNeural
|
||||
Gender: Male
|
||||
|
||||
Name: ml-IN-SobhanaNeural
|
||||
Gender: Female
|
||||
|
||||
Name: mn-MN-BataaNeural
|
||||
Gender: Male
|
||||
|
||||
Name: mn-MN-YesuiNeural
|
||||
Gender: Female
|
||||
|
||||
Name: mr-IN-AarohiNeural
|
||||
Gender: Female
|
||||
|
||||
Name: mr-IN-ManoharNeural
|
||||
Gender: Male
|
||||
|
||||
Name: ms-MY-OsmanNeural
|
||||
Gender: Male
|
||||
|
||||
Name: ms-MY-YasminNeural
|
||||
Gender: Female
|
||||
|
||||
Name: mt-MT-GraceNeural
|
||||
Gender: Female
|
||||
|
||||
Name: mt-MT-JosephNeural
|
||||
Gender: Male
|
||||
|
||||
Name: my-MM-NilarNeural
|
||||
Gender: Female
|
||||
|
||||
Name: my-MM-ThihaNeural
|
||||
Gender: Male
|
||||
|
||||
Name: nb-NO-FinnNeural
|
||||
Gender: Male
|
||||
|
||||
Name: nb-NO-PernilleNeural
|
||||
Gender: Female
|
||||
|
||||
Name: ne-NP-HemkalaNeural
|
||||
Gender: Female
|
||||
|
||||
Name: ne-NP-SagarNeural
|
||||
Gender: Male
|
||||
|
||||
Name: nl-BE-ArnaudNeural
|
||||
Gender: Male
|
||||
|
||||
Name: nl-BE-DenaNeural
|
||||
Gender: Female
|
||||
|
||||
Name: nl-NL-ColetteNeural
|
||||
Gender: Female
|
||||
|
||||
Name: nl-NL-FennaNeural
|
||||
Gender: Female
|
||||
|
||||
Name: nl-NL-MaartenNeural
|
||||
Gender: Male
|
||||
|
||||
Name: pl-PL-MarekNeural
|
||||
Gender: Male
|
||||
|
||||
Name: pl-PL-ZofiaNeural
|
||||
Gender: Female
|
||||
|
||||
Name: ps-AF-GulNawazNeural
|
||||
Gender: Male
|
||||
|
||||
Name: ps-AF-LatifaNeural
|
||||
Gender: Female
|
||||
|
||||
Name: pt-BR-AntonioNeural
|
||||
Gender: Male
|
||||
|
||||
Name: pt-BR-FranciscaNeural
|
||||
Gender: Female
|
||||
|
||||
Name: pt-BR-ThalitaNeural
|
||||
Gender: Female
|
||||
|
||||
Name: pt-PT-DuarteNeural
|
||||
Gender: Male
|
||||
|
||||
Name: pt-PT-RaquelNeural
|
||||
Gender: Female
|
||||
|
||||
Name: ro-RO-AlinaNeural
|
||||
Gender: Female
|
||||
|
||||
Name: ro-RO-EmilNeural
|
||||
Gender: Male
|
||||
|
||||
Name: ru-RU-DmitryNeural
|
||||
Gender: Male
|
||||
|
||||
Name: ru-RU-SvetlanaNeural
|
||||
Gender: Female
|
||||
|
||||
Name: si-LK-SameeraNeural
|
||||
Gender: Male
|
||||
|
||||
Name: si-LK-ThiliniNeural
|
||||
Gender: Female
|
||||
|
||||
Name: sk-SK-LukasNeural
|
||||
Gender: Male
|
||||
|
||||
Name: sk-SK-ViktoriaNeural
|
||||
Gender: Female
|
||||
|
||||
Name: sl-SI-PetraNeural
|
||||
Gender: Female
|
||||
|
||||
Name: sl-SI-RokNeural
|
||||
Gender: Male
|
||||
|
||||
Name: so-SO-MuuseNeural
|
||||
Gender: Male
|
||||
|
||||
Name: so-SO-UbaxNeural
|
||||
Gender: Female
|
||||
|
||||
Name: sq-AL-AnilaNeural
|
||||
Gender: Female
|
||||
|
||||
Name: sq-AL-IlirNeural
|
||||
Gender: Male
|
||||
|
||||
Name: sr-RS-NicholasNeural
|
||||
Gender: Male
|
||||
|
||||
Name: sr-RS-SophieNeural
|
||||
Gender: Female
|
||||
|
||||
Name: su-ID-JajangNeural
|
||||
Gender: Male
|
||||
|
||||
Name: su-ID-TutiNeural
|
||||
Gender: Female
|
||||
|
||||
Name: sv-SE-MattiasNeural
|
||||
Gender: Male
|
||||
|
||||
Name: sv-SE-SofieNeural
|
||||
Gender: Female
|
||||
|
||||
Name: sw-KE-RafikiNeural
|
||||
Gender: Male
|
||||
|
||||
Name: sw-KE-ZuriNeural
|
||||
Gender: Female
|
||||
|
||||
Name: sw-TZ-DaudiNeural
|
||||
Gender: Male
|
||||
|
||||
Name: sw-TZ-RehemaNeural
|
||||
Gender: Female
|
||||
|
||||
Name: ta-IN-PallaviNeural
|
||||
Gender: Female
|
||||
|
||||
Name: ta-IN-ValluvarNeural
|
||||
Gender: Male
|
||||
|
||||
Name: ta-LK-KumarNeural
|
||||
Gender: Male
|
||||
|
||||
Name: ta-LK-SaranyaNeural
|
||||
Gender: Female
|
||||
|
||||
Name: ta-MY-KaniNeural
|
||||
Gender: Female
|
||||
|
||||
Name: ta-MY-SuryaNeural
|
||||
Gender: Male
|
||||
|
||||
Name: ta-SG-AnbuNeural
|
||||
Gender: Male
|
||||
|
||||
Name: ta-SG-VenbaNeural
|
||||
Gender: Female
|
||||
|
||||
Name: te-IN-MohanNeural
|
||||
Gender: Male
|
||||
|
||||
Name: te-IN-ShrutiNeural
|
||||
Gender: Female
|
||||
|
||||
Name: th-TH-NiwatNeural
|
||||
Gender: Male
|
||||
|
||||
Name: th-TH-PremwadeeNeural
|
||||
Gender: Female
|
||||
|
||||
Name: tr-TR-AhmetNeural
|
||||
Gender: Male
|
||||
|
||||
Name: tr-TR-EmelNeural
|
||||
Gender: Female
|
||||
|
||||
Name: uk-UA-OstapNeural
|
||||
Gender: Male
|
||||
|
||||
Name: uk-UA-PolinaNeural
|
||||
Gender: Female
|
||||
|
||||
Name: ur-IN-GulNeural
|
||||
Gender: Female
|
||||
|
||||
Name: ur-IN-SalmanNeural
|
||||
Gender: Male
|
||||
|
||||
Name: ur-PK-AsadNeural
|
||||
Gender: Male
|
||||
|
||||
Name: ur-PK-UzmaNeural
|
||||
Gender: Female
|
||||
|
||||
Name: uz-UZ-MadinaNeural
|
||||
Gender: Female
|
||||
|
||||
Name: uz-UZ-SardorNeural
|
||||
Gender: Male
|
||||
|
||||
Name: vi-VN-HoaiMyNeural
|
||||
Gender: Female
|
||||
|
||||
Name: vi-VN-NamMinhNeural
|
||||
Gender: Male
|
||||
|
||||
Name: zh-CN-XiaoxiaoNeural
|
||||
Gender: Female
|
||||
|
||||
Name: zh-CN-XiaoyiNeural
|
||||
Gender: Female
|
||||
|
||||
Name: zh-CN-YunjianNeural
|
||||
Gender: Male
|
||||
|
||||
Name: zh-CN-YunxiNeural
|
||||
Gender: Male
|
||||
|
||||
Name: zh-CN-YunxiaNeural
|
||||
Gender: Male
|
||||
|
||||
Name: zh-CN-YunyangNeural
|
||||
Gender: Male
|
||||
|
||||
Name: zh-CN-liaoning-XiaobeiNeural
|
||||
Gender: Female
|
||||
|
||||
Name: zh-CN-shaanxi-XiaoniNeural
|
||||
Gender: Female
|
||||
|
||||
Name: zh-HK-HiuGaaiNeural
|
||||
Gender: Female
|
||||
|
||||
Name: zh-HK-HiuMaanNeural
|
||||
Gender: Female
|
||||
|
||||
Name: zh-HK-WanLungNeural
|
||||
Gender: Male
|
||||
|
||||
Name: zh-TW-HsiaoChenNeural
|
||||
Gender: Female
|
||||
|
||||
Name: zh-TW-HsiaoYuNeural
|
||||
Gender: Female
|
||||
|
||||
Name: zh-TW-YunJheNeural
|
||||
Gender: Male
|
||||
|
||||
Name: zu-ZA-ThandoNeural
|
||||
Gender: Female
|
||||
|
||||
Name: zu-ZA-ThembaNeural
|
||||
Gender: Male
|
||||
BIN
docs/webui.jpg
Normal file
BIN
docs/webui.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 241 KiB |
10
main.py
Normal file
10
main.py
Normal file
@@ -0,0 +1,10 @@
|
||||
import uvicorn
|
||||
from loguru import logger
|
||||
from app.config import config
|
||||
|
||||
import __init__ # noqa
|
||||
|
||||
if __name__ == '__main__':
|
||||
logger.info("start server, docs: http://127.0.0.1:" + str(config.listen_port) + "/docs")
|
||||
uvicorn.run(app="app.asgi:app", host=config.listen_host, port=config.listen_port, reload=config.reload_debug,
|
||||
log_level="warning")
|
||||
14
requirements.txt
Normal file
14
requirements.txt
Normal file
@@ -0,0 +1,14 @@
|
||||
requests~=2.31.0
|
||||
moviepy~=1.0.3
|
||||
openai~=1.13.3
|
||||
faster-whisper~=1.0.1
|
||||
edge_tts~=6.1.10
|
||||
uvicorn~=0.27.1
|
||||
fastapi~=0.110.0
|
||||
tomli~=2.0.1
|
||||
streamlit~=1.32.0
|
||||
loguru~=0.7.2
|
||||
aiohttp~=3.9.3
|
||||
urllib3~=2.2.1
|
||||
pillow~=10.2.0
|
||||
pydantic~=2.6.3
|
||||
BIN
resource/fonts/STHeitiLight.ttc
Normal file
BIN
resource/fonts/STHeitiLight.ttc
Normal file
Binary file not shown.
BIN
resource/fonts/STHeitiMedium.ttc
Normal file
BIN
resource/fonts/STHeitiMedium.ttc
Normal file
Binary file not shown.
BIN
resource/songs/output000.mp3
Normal file
BIN
resource/songs/output000.mp3
Normal file
Binary file not shown.
BIN
resource/songs/output001.mp3
Normal file
BIN
resource/songs/output001.mp3
Normal file
Binary file not shown.
BIN
resource/songs/output002.mp3
Normal file
BIN
resource/songs/output002.mp3
Normal file
Binary file not shown.
BIN
resource/songs/output003.mp3
Normal file
BIN
resource/songs/output003.mp3
Normal file
Binary file not shown.
BIN
resource/songs/output004.mp3
Normal file
BIN
resource/songs/output004.mp3
Normal file
Binary file not shown.
BIN
resource/songs/output005.mp3
Normal file
BIN
resource/songs/output005.mp3
Normal file
Binary file not shown.
BIN
resource/songs/output006.mp3
Normal file
BIN
resource/songs/output006.mp3
Normal file
Binary file not shown.
BIN
resource/songs/output007.mp3
Normal file
BIN
resource/songs/output007.mp3
Normal file
Binary file not shown.
BIN
resource/songs/output008.mp3
Normal file
BIN
resource/songs/output008.mp3
Normal file
Binary file not shown.
BIN
resource/songs/output009.mp3
Normal file
BIN
resource/songs/output009.mp3
Normal file
Binary file not shown.
BIN
resource/songs/output010.mp3
Normal file
BIN
resource/songs/output010.mp3
Normal file
Binary file not shown.
BIN
resource/songs/output011.mp3
Normal file
BIN
resource/songs/output011.mp3
Normal file
Binary file not shown.
BIN
resource/songs/output012.mp3
Normal file
BIN
resource/songs/output012.mp3
Normal file
Binary file not shown.
BIN
resource/songs/output013.mp3
Normal file
BIN
resource/songs/output013.mp3
Normal file
Binary file not shown.
BIN
resource/songs/output014.mp3
Normal file
BIN
resource/songs/output014.mp3
Normal file
Binary file not shown.
BIN
resource/songs/output015.mp3
Normal file
BIN
resource/songs/output015.mp3
Normal file
Binary file not shown.
BIN
resource/songs/output016.mp3
Normal file
BIN
resource/songs/output016.mp3
Normal file
Binary file not shown.
BIN
resource/songs/output017.mp3
Normal file
BIN
resource/songs/output017.mp3
Normal file
Binary file not shown.
BIN
resource/songs/output018.mp3
Normal file
BIN
resource/songs/output018.mp3
Normal file
Binary file not shown.
BIN
resource/songs/output019.mp3
Normal file
BIN
resource/songs/output019.mp3
Normal file
Binary file not shown.
BIN
resource/songs/output020.mp3
Normal file
BIN
resource/songs/output020.mp3
Normal file
Binary file not shown.
BIN
resource/songs/output021.mp3
Normal file
BIN
resource/songs/output021.mp3
Normal file
Binary file not shown.
BIN
resource/songs/output022.mp3
Normal file
BIN
resource/songs/output022.mp3
Normal file
Binary file not shown.
BIN
resource/songs/output023.mp3
Normal file
BIN
resource/songs/output023.mp3
Normal file
Binary file not shown.
BIN
resource/songs/output024.mp3
Normal file
BIN
resource/songs/output024.mp3
Normal file
Binary file not shown.
BIN
resource/songs/output025.mp3
Normal file
BIN
resource/songs/output025.mp3
Normal file
Binary file not shown.
BIN
resource/songs/output027.mp3
Normal file
BIN
resource/songs/output027.mp3
Normal file
Binary file not shown.
BIN
resource/songs/output028.mp3
Normal file
BIN
resource/songs/output028.mp3
Normal file
Binary file not shown.
BIN
resource/songs/output029.mp3
Normal file
BIN
resource/songs/output029.mp3
Normal file
Binary file not shown.
4
webui.sh
Normal file
4
webui.sh
Normal file
@@ -0,0 +1,4 @@
|
||||
CURRENT_DIR=$(pwd)
|
||||
echo "***** Current directory: $CURRENT_DIR *****"
|
||||
export PYTHONPATH="${CURRENT_DIR}:$PYTHONPATH"
|
||||
streamlit run ./webui/Main.py
|
||||
2
webui/.streamlit/config.toml
Normal file
2
webui/.streamlit/config.toml
Normal file
@@ -0,0 +1,2 @@
|
||||
[browser]
|
||||
gatherUsageStats = false
|
||||
153
webui/Main.py
Normal file
153
webui/Main.py
Normal file
@@ -0,0 +1,153 @@
|
||||
import sys
|
||||
import os
|
||||
from uuid import uuid4
|
||||
import streamlit as st
|
||||
from loguru import logger
|
||||
from app.models.schema import VideoParams, VideoAspect, VoiceNames
|
||||
from app.services import task as tm
|
||||
|
||||
st.set_page_config(page_title="MoneyPrinterTurbo", page_icon="🤖", layout="wide",
|
||||
initial_sidebar_state="auto")
|
||||
st.title("MoneyPrinterTurbo")
|
||||
|
||||
root_dir = os.path.dirname(os.path.dirname(os.path.realpath(__file__)))
|
||||
font_dir = os.path.join(root_dir, "resource", "fonts")
|
||||
song_dir = os.path.join(root_dir, "resource", "songs")
|
||||
|
||||
|
||||
def get_all_fonts():
|
||||
fonts = []
|
||||
for root, dirs, files in os.walk(font_dir):
|
||||
for file in files:
|
||||
if file.endswith(".ttf") or file.endswith(".ttc"):
|
||||
fonts.append(file)
|
||||
return fonts
|
||||
|
||||
|
||||
def get_all_songs():
|
||||
songs = []
|
||||
for root, dirs, files in os.walk(song_dir):
|
||||
for file in files:
|
||||
if file.endswith(".mp3"):
|
||||
songs.append(file)
|
||||
return songs
|
||||
|
||||
|
||||
def init_log():
|
||||
logger.remove()
|
||||
_lvl = "DEBUG"
|
||||
|
||||
def format_record(record):
|
||||
# 获取日志记录中的文件全路径
|
||||
file_path = record["file"].path
|
||||
# 将绝对路径转换为相对于项目根目录的路径
|
||||
relative_path = os.path.relpath(file_path, root_dir)
|
||||
# 更新记录中的文件路径
|
||||
record["file"].path = f"./{relative_path}"
|
||||
# 返回修改后的格式字符串
|
||||
# 您可以根据需要调整这里的格式
|
||||
record['message'] = record['message'].replace(root_dir, ".")
|
||||
|
||||
_format = '<green>{time:%Y-%m-%d %H:%M:%S}</> | ' + \
|
||||
'<level>{level}</> | ' + \
|
||||
'"{file.path}:{line}":<blue> {function}</> ' + \
|
||||
'- <level>{message}</>' + "\n"
|
||||
return _format
|
||||
|
||||
logger.add(
|
||||
sys.stdout,
|
||||
level=_lvl,
|
||||
format=format_record,
|
||||
colorize=True,
|
||||
)
|
||||
|
||||
|
||||
init_log()
|
||||
|
||||
panel = st.columns(2)
|
||||
left_panel = panel[0]
|
||||
right_panel = panel[1]
|
||||
|
||||
# define cfg as VideoParams class
|
||||
cfg = VideoParams()
|
||||
|
||||
with left_panel:
|
||||
with st.container(border=True):
|
||||
st.write("**视频设置**")
|
||||
cfg.video_subject = st.text_area("视频主题", help="请输入视频主题")
|
||||
video_aspect_ratios = [
|
||||
("竖屏 9:16(抖音视频)", VideoAspect.portrait.value),
|
||||
("横屏 16:9(西瓜视频)", VideoAspect.landscape.value),
|
||||
# ("方形 1:1", VideoAspect.square.value)
|
||||
]
|
||||
selected_index = st.selectbox("视频比例",
|
||||
options=range(len(video_aspect_ratios)), # 使用索引作为内部选项值
|
||||
format_func=lambda x: video_aspect_ratios[x][0] # 显示给用户的是标签
|
||||
)
|
||||
cfg.video_aspect = VideoAspect(video_aspect_ratios[selected_index][1])
|
||||
|
||||
with st.container(border=True):
|
||||
st.write("**声音设置**")
|
||||
# 创建一个映射字典,将原始值映射到友好名称
|
||||
friendly_names = {
|
||||
voice: voice.
|
||||
replace("female", "女性").
|
||||
replace("male", "男性").
|
||||
replace("zh-CN", "中文").
|
||||
replace("zh-HK", "香港").
|
||||
replace("zh-TW", "台湾").
|
||||
replace("en-US", "英文").
|
||||
replace("Neural", "") for
|
||||
voice in VoiceNames}
|
||||
selected_friendly_name = st.selectbox("声音", options=list(friendly_names.values()))
|
||||
voice_name = list(friendly_names.keys())[list(friendly_names.values()).index(selected_friendly_name)]
|
||||
cfg.voice_name = voice_name
|
||||
|
||||
song_names = [
|
||||
("无背景音乐 No BGM", ""),
|
||||
("随机背景音乐 Random BGM", "random"),
|
||||
*[(song, song) for song in get_all_songs()]
|
||||
]
|
||||
selected_index = st.selectbox("背景音乐",
|
||||
index=1,
|
||||
options=range(len(song_names)), # 使用索引作为内部选项值
|
||||
format_func=lambda x: song_names[x][0] # 显示给用户的是标签
|
||||
)
|
||||
cfg.bgm_name = song_names[selected_index][1]
|
||||
|
||||
with right_panel:
|
||||
with st.container(border=True):
|
||||
st.write("**字幕设置**")
|
||||
font_names = get_all_fonts()
|
||||
cfg.font_name = st.selectbox("字体", font_names)
|
||||
cfg.text_fore_color = st.color_picker("字幕颜色", "#FFFFFF")
|
||||
cfg.font_size = st.slider("字幕大小", 30, 100, 60)
|
||||
cfg.stroke_color = st.color_picker("描边颜色", "#000000")
|
||||
cfg.stroke_width = st.slider("描边粗细", 0.0, 10.0, 1.5)
|
||||
|
||||
start_button = st.button("开始生成视频", use_container_width=True, type="primary")
|
||||
if start_button:
|
||||
task_id = str(uuid4())
|
||||
st.session_state['started'] = True
|
||||
if not cfg.video_subject:
|
||||
st.error("视频主题不能为空")
|
||||
st.stop()
|
||||
|
||||
st.write(cfg)
|
||||
|
||||
log_container = st.empty()
|
||||
|
||||
log_records = []
|
||||
|
||||
|
||||
def log_received(msg):
|
||||
with log_container:
|
||||
log_records.append(msg)
|
||||
st.code("\n".join(log_records))
|
||||
|
||||
|
||||
logger.add(log_received)
|
||||
|
||||
logger.info("开始生成视频")
|
||||
|
||||
tm.start(task_id=task_id, params=cfg)
|
||||
Reference in New Issue
Block a user