初始化提交
This commit is contained in:
0
app/__init__.py
Normal file
0
app/__init__.py
Normal file
82
app/asgi.py
Normal file
82
app/asgi.py
Normal file
@@ -0,0 +1,82 @@
|
||||
"""Application implementation - ASGI."""
|
||||
|
||||
import os
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from loguru import logger
|
||||
|
||||
from app.config import config
|
||||
from app.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()
|
||||
|
||||
# Configures the CORS middleware for the FastAPI app
|
||||
cors_allowed_origins_str = os.getenv("CORS_ALLOWED_ORIGINS", "")
|
||||
origins = cors_allowed_origins_str.split(",") if cors_allowed_origins_str else ["*"]
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=origins,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
task_dir = utils.task_dir()
|
||||
app.mount(
|
||||
"/tasks", StaticFiles(directory=task_dir, html=True, follow_symlink=True), name=""
|
||||
)
|
||||
|
||||
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")
|
||||
56
app/config/__init__.py
Normal file
56
app/config/__init__.py
Normal file
@@ -0,0 +1,56 @@
|
||||
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()
|
||||
198
app/config/config.py
Normal file
198
app/config/config.py
Normal file
@@ -0,0 +1,198 @@
|
||||
import os
|
||||
import shutil
|
||||
import socket
|
||||
|
||||
import toml
|
||||
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"
|
||||
_CONTAINER_CGROUP_MARKERS = ("docker", "containerd", "kubepods", "libpod", "podman")
|
||||
_DOCKER_HOST_GATEWAY_NAME = "host.docker.internal"
|
||||
|
||||
|
||||
def is_running_in_container(
|
||||
dockerenv_path: str = "/.dockerenv",
|
||||
containerenv_path: str = "/run/.containerenv",
|
||||
cgroup_path: str = "/proc/1/cgroup",
|
||||
) -> bool:
|
||||
"""
|
||||
判断当前进程是否运行在容器内。
|
||||
|
||||
这个判断主要用于 Ollama 默认地址选择:
|
||||
- 普通本机运行时,`localhost` 指向用户机器本身;
|
||||
- Docker 容器内,`localhost` 指向容器自己,访问宿主机 Ollama
|
||||
通常需要使用 `host.docker.internal`。
|
||||
|
||||
不能只判断 `/proc/1/cgroup` 是否存在,因为普通 Linux 也会有这个文件。
|
||||
这里只在检测到明确的容器标记时返回 True,避免误伤非 Docker Linux 用户。
|
||||
参数保留为可注入路径,便于单元测试覆盖不同运行环境。
|
||||
"""
|
||||
if os.path.isfile(dockerenv_path) or os.path.isfile(containerenv_path):
|
||||
return True
|
||||
|
||||
try:
|
||||
with open(cgroup_path, mode="r", encoding="utf-8") as fp:
|
||||
cgroup_content = fp.read().lower()
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
return any(marker in cgroup_content for marker in _CONTAINER_CGROUP_MARKERS)
|
||||
|
||||
|
||||
def _can_resolve_hostname(hostname: str) -> bool:
|
||||
try:
|
||||
socket.gethostbyname(hostname)
|
||||
except OSError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _decode_linux_route_gateway(hex_gateway: str) -> str:
|
||||
# /proc/net/route 里的 Gateway 是 16 进制小端序,例如 010011AC 表示
|
||||
# 172.17.0.1。这里单独解析,是为了在原生 Linux Docker 没有
|
||||
# host.docker.internal DNS 记录时,还能尝试访问容器默认网关上的宿主机。
|
||||
if len(hex_gateway) != 8:
|
||||
raise ValueError("invalid gateway length")
|
||||
|
||||
octets = [
|
||||
str(int(hex_gateway[index : index + 2], 16))
|
||||
for index in range(6, -1, -2)
|
||||
]
|
||||
return ".".join(octets)
|
||||
|
||||
|
||||
def get_container_default_gateway_ip(route_path: str = "/proc/net/route") -> str:
|
||||
"""
|
||||
读取 Linux 容器里的默认网关 IP。
|
||||
|
||||
Docker Desktop 通常提供 `host.docker.internal`,但原生 Linux Docker
|
||||
默认不一定提供这个 DNS 名称。默认网关通常可以作为访问宿主机服务的
|
||||
兜底地址;如果用户的 Ollama 只监听 127.0.0.1,则仍需要用户让
|
||||
Ollama 监听宿主机网卡或手动配置 `ollama_base_url`。
|
||||
"""
|
||||
try:
|
||||
with open(route_path, mode="r", encoding="utf-8") as fp:
|
||||
route_lines = fp.readlines()
|
||||
except OSError:
|
||||
return ""
|
||||
|
||||
for line in route_lines[1:]:
|
||||
fields = line.strip().split()
|
||||
if len(fields) < 3:
|
||||
continue
|
||||
|
||||
destination = fields[1]
|
||||
gateway = fields[2]
|
||||
if destination != "00000000" or gateway == "00000000":
|
||||
continue
|
||||
|
||||
try:
|
||||
return _decode_linux_route_gateway(gateway)
|
||||
except ValueError:
|
||||
logger.warning(f"invalid container gateway route entry: {line.strip()}")
|
||||
return ""
|
||||
|
||||
return ""
|
||||
|
||||
|
||||
def get_default_ollama_base_url() -> str:
|
||||
"""
|
||||
返回 Ollama 的默认 OpenAI-compatible base_url。
|
||||
|
||||
用户显式配置 `ollama_base_url` 时不会走这里;这里只处理“未配置时的
|
||||
最佳默认值”。容器内默认指向宿主机,普通本机运行默认指向 localhost。
|
||||
"""
|
||||
if not is_running_in_container():
|
||||
return "http://localhost:11434/v1"
|
||||
|
||||
if _can_resolve_hostname(_DOCKER_HOST_GATEWAY_NAME):
|
||||
return f"http://{_DOCKER_HOST_GATEWAY_NAME}:11434/v1"
|
||||
|
||||
gateway_ip = get_container_default_gateway_ip()
|
||||
if gateway_ip:
|
||||
logger.info(
|
||||
"host.docker.internal is not resolvable, fallback to container "
|
||||
f"default gateway for Ollama: {gateway_ip}"
|
||||
)
|
||||
return f"http://{gateway_ip}:11434/v1"
|
||||
|
||||
logger.warning(
|
||||
"failed to resolve host.docker.internal and container default gateway; "
|
||||
"fallback to host.docker.internal for Ollama"
|
||||
)
|
||||
return f"http://{_DOCKER_HOST_GATEWAY_NAME}:11434/v1"
|
||||
|
||||
|
||||
def load_config():
|
||||
# fix: IsADirectoryError: [Errno 21] Is a directory: '/MoneyPrinterTurbo/config.toml'
|
||||
if os.path.isdir(config_file):
|
||||
shutil.rmtree(config_file)
|
||||
|
||||
if not os.path.isfile(config_file):
|
||||
example_file = f"{root_dir}/config.example.toml"
|
||||
if os.path.isfile(example_file):
|
||||
shutil.copyfile(example_file, config_file)
|
||||
logger.info("copy config.example.toml to config.toml")
|
||||
|
||||
logger.info(f"load config from file: {config_file}")
|
||||
|
||||
try:
|
||||
_config_ = toml.load(config_file)
|
||||
except Exception as e:
|
||||
logger.warning(f"load config failed: {str(e)}, try to load as utf-8-sig")
|
||||
with open(config_file, mode="r", encoding="utf-8-sig") as fp:
|
||||
_cfg_content = fp.read()
|
||||
_config_ = toml.loads(_cfg_content)
|
||||
return _config_
|
||||
|
||||
|
||||
def save_config():
|
||||
with open(config_file, "w", encoding="utf-8") as f:
|
||||
_cfg["app"] = app
|
||||
_cfg["azure"] = azure
|
||||
_cfg["siliconflow"] = siliconflow
|
||||
_cfg["ui"] = ui
|
||||
f.write(toml.dumps(_cfg))
|
||||
|
||||
|
||||
_cfg = load_config()
|
||||
app = _cfg.get("app", {})
|
||||
whisper = _cfg.get("whisper", {})
|
||||
proxy = _cfg.get("proxy", {})
|
||||
azure = _cfg.get("azure", {})
|
||||
siliconflow = _cfg.get("siliconflow", {})
|
||||
ui = _cfg.get(
|
||||
"ui",
|
||||
{
|
||||
"hide_log": False,
|
||||
},
|
||||
)
|
||||
|
||||
hostname = socket.gethostname()
|
||||
|
||||
log_level = _cfg.get("log_level", "DEBUG")
|
||||
listen_host = _cfg.get("listen_host", "0.0.0.0")
|
||||
listen_port = _cfg.get("listen_port", 8099)
|
||||
project_name = _cfg.get("project_name", "MoneyPrinterTurbo")
|
||||
project_description = _cfg.get(
|
||||
"project_description",
|
||||
"<a href='https://github.com/harry0703/MoneyPrinterTurbo'>https://github.com/harry0703/MoneyPrinterTurbo</a>",
|
||||
)
|
||||
project_version = _cfg.get("project_version", "1.2.9")
|
||||
reload_debug = False
|
||||
|
||||
app["redis_host"] = os.getenv(
|
||||
"MPT_APP_REDIS_HOST",
|
||||
os.getenv("REDIS_HOST", app.get("redis_host", "localhost")),
|
||||
)
|
||||
|
||||
imagemagick_path = app.get("imagemagick_path", "")
|
||||
if imagemagick_path and os.path.isfile(imagemagick_path):
|
||||
os.environ["IMAGEMAGICK_BINARY"] = imagemagick_path
|
||||
|
||||
ffmpeg_path = app.get("ffmpeg_path", "")
|
||||
if ffmpeg_path and os.path.isfile(ffmpeg_path):
|
||||
os.environ["IMAGEIO_FFMPEG_EXE"] = ffmpeg_path
|
||||
|
||||
logger.info(f"{project_name} v{project_version}")
|
||||
31
app/controllers/base.py
Normal file
31
app/controllers/base.py
Normal file
@@ -0,0 +1,31 @@
|
||||
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}",
|
||||
)
|
||||
87
app/controllers/manager/base_manager.py
Normal file
87
app/controllers/manager/base_manager.py
Normal file
@@ -0,0 +1,87 @@
|
||||
import threading
|
||||
from typing import Any, Callable, Dict
|
||||
|
||||
from loguru import logger
|
||||
|
||||
|
||||
class TaskQueueFullError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
class TaskManager:
|
||||
def __init__(self, max_concurrent_tasks: int, max_queued_tasks: int = 100):
|
||||
self.max_concurrent_tasks = max_concurrent_tasks
|
||||
self.max_queued_tasks = max_queued_tasks
|
||||
self.current_tasks = 0
|
||||
self.lock = threading.Lock()
|
||||
self.queue = self.create_queue()
|
||||
|
||||
def create_queue(self):
|
||||
raise NotImplementedError()
|
||||
|
||||
def add_task(self, func: Callable, *args: Any, **kwargs: Any):
|
||||
with self.lock:
|
||||
if self.current_tasks < self.max_concurrent_tasks:
|
||||
logger.info(
|
||||
f"add task: {func.__name__}, current_tasks: {self.current_tasks}"
|
||||
)
|
||||
self.execute_task(func, *args, **kwargs)
|
||||
else:
|
||||
queue_size = self.queue_size()
|
||||
# 并发数已满时才进入排队。队列必须有上限,否则匿名接口可以持续
|
||||
# 堆积任务对象和请求参数,最终造成内存耗尽或第三方 API 成本失控。
|
||||
if queue_size >= self.max_queued_tasks:
|
||||
logger.warning(
|
||||
f"reject task: {func.__name__}, queue_size: {queue_size}, "
|
||||
f"max_queued_tasks: {self.max_queued_tasks}"
|
||||
)
|
||||
raise TaskQueueFullError("task queue is full, please try again later")
|
||||
|
||||
logger.info(
|
||||
f"enqueue task: {func.__name__}, current_tasks: {self.current_tasks}, "
|
||||
f"queue_size: {queue_size}"
|
||||
)
|
||||
self.enqueue({"func": func, "args": args, "kwargs": kwargs})
|
||||
|
||||
def execute_task(self, func: Callable, *args: Any, **kwargs: Any):
|
||||
thread = threading.Thread(
|
||||
target=self.run_task, args=(func, *args), kwargs=kwargs
|
||||
)
|
||||
thread.start()
|
||||
|
||||
def run_task(self, func: Callable, *args: Any, **kwargs: Any):
|
||||
try:
|
||||
with self.lock:
|
||||
self.current_tasks += 1
|
||||
func(*args, **kwargs) # call the function here, passing *args and **kwargs.
|
||||
finally:
|
||||
self.task_done()
|
||||
|
||||
def check_queue(self):
|
||||
with self.lock:
|
||||
if (
|
||||
self.current_tasks < self.max_concurrent_tasks
|
||||
and not self.is_queue_empty()
|
||||
):
|
||||
task_info = self.dequeue()
|
||||
func = task_info["func"]
|
||||
args = task_info.get("args", ())
|
||||
kwargs = task_info.get("kwargs", {})
|
||||
self.execute_task(func, *args, **kwargs)
|
||||
|
||||
def task_done(self):
|
||||
with self.lock:
|
||||
self.current_tasks -= 1
|
||||
self.check_queue()
|
||||
|
||||
def enqueue(self, task: Dict):
|
||||
raise NotImplementedError()
|
||||
|
||||
def dequeue(self):
|
||||
raise NotImplementedError()
|
||||
|
||||
def is_queue_empty(self):
|
||||
raise NotImplementedError()
|
||||
|
||||
def queue_size(self):
|
||||
raise NotImplementedError()
|
||||
21
app/controllers/manager/memory_manager.py
Normal file
21
app/controllers/manager/memory_manager.py
Normal file
@@ -0,0 +1,21 @@
|
||||
from queue import Queue
|
||||
from typing import Dict
|
||||
|
||||
from app.controllers.manager.base_manager import TaskManager
|
||||
|
||||
|
||||
class InMemoryTaskManager(TaskManager):
|
||||
def create_queue(self):
|
||||
return Queue(maxsize=self.max_queued_tasks)
|
||||
|
||||
def enqueue(self, task: Dict):
|
||||
self.queue.put(task)
|
||||
|
||||
def dequeue(self):
|
||||
return self.queue.get()
|
||||
|
||||
def is_queue_empty(self):
|
||||
return self.queue.empty()
|
||||
|
||||
def queue_size(self):
|
||||
return self.queue.qsize()
|
||||
64
app/controllers/manager/redis_manager.py
Normal file
64
app/controllers/manager/redis_manager.py
Normal file
@@ -0,0 +1,64 @@
|
||||
import json
|
||||
from typing import Dict
|
||||
|
||||
import redis
|
||||
|
||||
from app.controllers.manager.base_manager import TaskManager
|
||||
from app.models.schema import VideoParams
|
||||
from app.services import task as tm
|
||||
|
||||
FUNC_MAP = {
|
||||
"start": tm.start,
|
||||
# 'start_test': tm.start_test
|
||||
}
|
||||
|
||||
|
||||
class RedisTaskManager(TaskManager):
|
||||
def __init__(
|
||||
self,
|
||||
max_concurrent_tasks: int,
|
||||
redis_url: str,
|
||||
max_queued_tasks: int = 100,
|
||||
):
|
||||
self.redis_client = redis.Redis.from_url(redis_url)
|
||||
super().__init__(max_concurrent_tasks, max_queued_tasks=max_queued_tasks)
|
||||
|
||||
def create_queue(self):
|
||||
return "task_queue"
|
||||
|
||||
def enqueue(self, task: Dict):
|
||||
task_with_serializable_params = task.copy()
|
||||
|
||||
if "params" in task["kwargs"] and isinstance(
|
||||
task["kwargs"]["params"], VideoParams
|
||||
):
|
||||
task_with_serializable_params["kwargs"]["params"] = task["kwargs"][
|
||||
"params"
|
||||
].dict()
|
||||
|
||||
# 将函数对象转换为其名称
|
||||
task_with_serializable_params["func"] = task["func"].__name__
|
||||
self.redis_client.rpush(self.queue, json.dumps(task_with_serializable_params))
|
||||
|
||||
def dequeue(self):
|
||||
task_json = self.redis_client.lpop(self.queue)
|
||||
if task_json:
|
||||
task_info = json.loads(task_json)
|
||||
# 将函数名称转换回函数对象
|
||||
task_info["func"] = FUNC_MAP[task_info["func"]]
|
||||
|
||||
if "params" in task_info["kwargs"] and isinstance(
|
||||
task_info["kwargs"]["params"], dict
|
||||
):
|
||||
task_info["kwargs"]["params"] = VideoParams(
|
||||
**task_info["kwargs"]["params"]
|
||||
)
|
||||
|
||||
return task_info
|
||||
return None
|
||||
|
||||
def is_queue_empty(self):
|
||||
return self.redis_client.llen(self.queue) == 0
|
||||
|
||||
def queue_size(self):
|
||||
return self.redis_client.llen(self.queue)
|
||||
13
app/controllers/ping.py
Normal file
13
app/controllers/ping.py
Normal file
@@ -0,0 +1,13 @@
|
||||
from fastapi import APIRouter, 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
|
||||
|
||||
|
||||
def new_router(dependencies=None):
|
||||
router = APIRouter()
|
||||
router.tags = ["V1"]
|
||||
router.prefix = "/api/v1"
|
||||
# 将认证依赖项应用于所有路由
|
||||
if dependencies:
|
||||
router.dependencies = dependencies
|
||||
return router
|
||||
47
app/controllers/v1/llm.py
Normal file
47
app/controllers/v1/llm.py
Normal file
@@ -0,0 +1,47 @@
|
||||
from fastapi import Request
|
||||
|
||||
from app.controllers.v1.base import new_router
|
||||
from app.models.schema import (
|
||||
VideoScriptRequest,
|
||||
VideoScriptResponse,
|
||||
VideoTermsRequest,
|
||||
VideoTermsResponse,
|
||||
)
|
||||
from app.services import llm
|
||||
from app.utils import utils
|
||||
|
||||
# authentication dependency
|
||||
# router = new_router(dependencies=[Depends(base.verify_token)])
|
||||
router = new_router()
|
||||
|
||||
|
||||
@router.post(
|
||||
"/scripts",
|
||||
response_model=VideoScriptResponse,
|
||||
summary="Create a script for the video",
|
||||
)
|
||||
def generate_video_script(request: Request, body: VideoScriptRequest):
|
||||
video_script = llm.generate_script(
|
||||
video_subject=body.video_subject,
|
||||
language=body.video_language,
|
||||
paragraph_number=body.paragraph_number,
|
||||
video_script_prompt=body.video_script_prompt,
|
||||
custom_system_prompt=body.custom_system_prompt,
|
||||
)
|
||||
response = {"video_script": video_script}
|
||||
return utils.get_response(200, response)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/terms",
|
||||
response_model=VideoTermsResponse,
|
||||
summary="Generate video terms based on the video script",
|
||||
)
|
||||
def generate_video_terms(request: Request, body: VideoTermsRequest):
|
||||
video_terms = llm.generate_terms(
|
||||
video_subject=body.video_subject,
|
||||
video_script=body.video_script,
|
||||
amount=body.amount,
|
||||
)
|
||||
response = {"video_terms": video_terms}
|
||||
return utils.get_response(200, response)
|
||||
403
app/controllers/v1/video.py
Normal file
403
app/controllers/v1/video.py
Normal file
@@ -0,0 +1,403 @@
|
||||
import glob
|
||||
import os
|
||||
import pathlib
|
||||
import shutil
|
||||
from typing import Union
|
||||
|
||||
from fastapi import BackgroundTasks, Depends, Path, Request, UploadFile
|
||||
from fastapi.params import File
|
||||
from fastapi.responses import FileResponse, StreamingResponse
|
||||
from loguru import logger
|
||||
|
||||
from app.config import config
|
||||
from app.controllers import base
|
||||
from app.controllers.manager.base_manager import TaskQueueFullError
|
||||
from app.controllers.manager.memory_manager import InMemoryTaskManager
|
||||
from app.controllers.manager.redis_manager import RedisTaskManager
|
||||
from app.controllers.v1.base import new_router
|
||||
from app.models.exception import HttpException
|
||||
from app.models.schema import (
|
||||
AudioRequest,
|
||||
BgmRetrieveResponse,
|
||||
BgmUploadResponse,
|
||||
SubtitleRequest,
|
||||
TaskDeletionResponse,
|
||||
TaskQueryRequest,
|
||||
TaskQueryResponse,
|
||||
TaskResponse,
|
||||
TaskVideoRequest,
|
||||
VideoMaterialUploadResponse,
|
||||
VideoMaterialRetrieveResponse
|
||||
)
|
||||
from app.services import state as sm
|
||||
from app.services import task as tm
|
||||
from app.utils import file_security, utils
|
||||
|
||||
# 认证依赖项
|
||||
# router = new_router(dependencies=[Depends(base.verify_token)])
|
||||
router = new_router()
|
||||
|
||||
_enable_redis = config.app.get("enable_redis", False)
|
||||
_redis_host = config.app.get("redis_host", "localhost")
|
||||
_redis_port = config.app.get("redis_port", 6379)
|
||||
_redis_db = config.app.get("redis_db", 0)
|
||||
_redis_password = config.app.get("redis_password", None)
|
||||
_max_concurrent_tasks = config.app.get("max_concurrent_tasks", 5)
|
||||
_max_queued_tasks = config.app.get("max_queued_tasks", 100)
|
||||
|
||||
redis_url = f"redis://:{_redis_password}@{_redis_host}:{_redis_port}/{_redis_db}"
|
||||
# 根据配置选择合适的任务管理器
|
||||
if _enable_redis:
|
||||
task_manager = RedisTaskManager(
|
||||
max_concurrent_tasks=_max_concurrent_tasks,
|
||||
redis_url=redis_url,
|
||||
max_queued_tasks=_max_queued_tasks,
|
||||
)
|
||||
else:
|
||||
task_manager = InMemoryTaskManager(
|
||||
max_concurrent_tasks=_max_concurrent_tasks,
|
||||
max_queued_tasks=_max_queued_tasks,
|
||||
)
|
||||
|
||||
|
||||
def _sanitize_upload_filename(filename: str, request_id: str) -> str:
|
||||
# 浏览器或客户端有时会附带目录信息,甚至可能夹带 ../ 这类穿越片段。
|
||||
# 这里只保留纯文件名,避免上传接口把文件写到目标目录之外。
|
||||
normalized_name = (filename or "").replace("\\", "/").split("/")[-1].strip()
|
||||
if not normalized_name or normalized_name in {".", ".."}:
|
||||
raise HttpException(
|
||||
task_id=request_id,
|
||||
status_code=400,
|
||||
message=f"{request_id}: invalid filename",
|
||||
)
|
||||
return normalized_name
|
||||
|
||||
|
||||
def _resolve_path_within_directory(base_dir: str, unsafe_path: str, request_id: str) -> str:
|
||||
try:
|
||||
return file_security.resolve_path_within_directory(base_dir, unsafe_path)
|
||||
except ValueError as exc:
|
||||
logger.warning(
|
||||
f"reject unsafe file path, request_id: {request_id}, path: {unsafe_path}, "
|
||||
f"error: {str(exc)}"
|
||||
)
|
||||
raise HttpException(
|
||||
task_id=request_id,
|
||||
status_code=404 if str(exc) == "file does not exist" else 403,
|
||||
message=f"{request_id}: invalid file path",
|
||||
)
|
||||
|
||||
def _task_file_to_uri(file: str, endpoint: str, task_dir: str, request_id: str) -> str:
|
||||
if not isinstance(file, str):
|
||||
return file
|
||||
|
||||
if file.startswith(("http://", "https://")):
|
||||
return file
|
||||
|
||||
try:
|
||||
resolved_path = file_security.resolve_path_within_directory(task_dir, file)
|
||||
except ValueError as exc:
|
||||
# 任务状态理论上只应保存任务目录内的产物路径。这里不再继续拼接 URL,
|
||||
# 避免把异常路径包装成可访问链接;同时保留原值,便于排查历史脏数据。
|
||||
logger.warning(
|
||||
f"skip unsafe task output path, request_id: {request_id}, path: {file}, "
|
||||
f"error: {str(exc)}"
|
||||
)
|
||||
return file
|
||||
|
||||
relative_path = os.path.relpath(resolved_path, task_dir).replace("\\", "/")
|
||||
uri_path = f"tasks/{relative_path}"
|
||||
if endpoint:
|
||||
return f"{endpoint.rstrip('/')}/{uri_path}"
|
||||
return f"/{uri_path}"
|
||||
|
||||
|
||||
@router.post("/videos", response_model=TaskResponse, summary="Generate a short video")
|
||||
def create_video(
|
||||
background_tasks: BackgroundTasks, request: Request, body: TaskVideoRequest
|
||||
):
|
||||
return create_task(request, body, stop_at="video")
|
||||
|
||||
|
||||
@router.post("/subtitle", response_model=TaskResponse, summary="Generate subtitle only")
|
||||
def create_subtitle(
|
||||
background_tasks: BackgroundTasks, request: Request, body: SubtitleRequest
|
||||
):
|
||||
return create_task(request, body, stop_at="subtitle")
|
||||
|
||||
|
||||
@router.post("/audio", response_model=TaskResponse, summary="Generate audio only")
|
||||
def create_audio(
|
||||
background_tasks: BackgroundTasks, request: Request, body: AudioRequest
|
||||
):
|
||||
return create_task(request, body, stop_at="audio")
|
||||
|
||||
|
||||
def create_task(
|
||||
request: Request,
|
||||
body: Union[TaskVideoRequest, SubtitleRequest, AudioRequest],
|
||||
stop_at: str,
|
||||
):
|
||||
task_id = utils.get_uuid()
|
||||
request_id = base.get_task_id(request)
|
||||
try:
|
||||
task = {
|
||||
"task_id": task_id,
|
||||
"request_id": request_id,
|
||||
"params": body.model_dump(),
|
||||
}
|
||||
sm.state.update_task(task_id)
|
||||
task_manager.add_task(tm.start, task_id=task_id, params=body, stop_at=stop_at)
|
||||
logger.success(f"Task created: {utils.to_json(task)}")
|
||||
return utils.get_response(200, task)
|
||||
except TaskQueueFullError as e:
|
||||
sm.state.delete_task(task_id)
|
||||
logger.warning(
|
||||
f"reject task because queue is full, request_id: {request_id}, task_id: {task_id}"
|
||||
)
|
||||
raise HttpException(
|
||||
task_id=task_id, status_code=429, message=f"{request_id}: {str(e)}"
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HttpException(
|
||||
task_id=task_id, status_code=400, message=f"{request_id}: {str(e)}"
|
||||
)
|
||||
|
||||
from fastapi import Query
|
||||
|
||||
@router.get("/tasks", response_model=TaskQueryResponse, summary="Get all tasks")
|
||||
def get_all_tasks(request: Request, page: int = Query(1, ge=1), page_size: int = Query(10, ge=1)):
|
||||
request_id = base.get_task_id(request)
|
||||
tasks, total = sm.state.get_all_tasks(page, page_size)
|
||||
|
||||
response = {
|
||||
"tasks": tasks,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
}
|
||||
return utils.get_response(200, response)
|
||||
|
||||
|
||||
|
||||
@router.get(
|
||||
"/tasks/{task_id}", response_model=TaskQueryResponse, summary="Query task status"
|
||||
)
|
||||
def get_task(
|
||||
request: Request,
|
||||
task_id: str = Path(..., description="Task ID"),
|
||||
query: TaskQueryRequest = Depends(),
|
||||
):
|
||||
request_id = base.get_task_id(request)
|
||||
endpoint = config.app.get("endpoint", "").rstrip("/")
|
||||
task = sm.state.get_task(task_id)
|
||||
if task:
|
||||
task_dir = utils.task_dir()
|
||||
response_task = dict(task)
|
||||
|
||||
if "videos" in task:
|
||||
response_task["videos"] = [
|
||||
_task_file_to_uri(v, endpoint, task_dir, request_id)
|
||||
for v in task["videos"]
|
||||
]
|
||||
if "combined_videos" in task:
|
||||
response_task["combined_videos"] = [
|
||||
_task_file_to_uri(v, endpoint, task_dir, request_id)
|
||||
for v in task["combined_videos"]
|
||||
]
|
||||
return utils.get_response(200, response_task)
|
||||
|
||||
raise HttpException(
|
||||
task_id=task_id, status_code=404, message=f"{request_id}: task not found"
|
||||
)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/tasks/{task_id}",
|
||||
response_model=TaskDeletionResponse,
|
||||
summary="Delete a generated short video task",
|
||||
)
|
||||
def delete_video(request: Request, task_id: str = Path(..., description="Task ID")):
|
||||
request_id = base.get_task_id(request)
|
||||
task = sm.state.get_task(task_id)
|
||||
if task:
|
||||
tasks_dir = utils.task_dir()
|
||||
current_task_dir = os.path.join(tasks_dir, task_id)
|
||||
if os.path.exists(current_task_dir):
|
||||
shutil.rmtree(current_task_dir)
|
||||
|
||||
sm.state.delete_task(task_id)
|
||||
logger.success(f"video deleted: {utils.to_json(task)}")
|
||||
return utils.get_response(200)
|
||||
|
||||
raise HttpException(
|
||||
task_id=task_id, status_code=404, message=f"{request_id}: task not found"
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/musics", response_model=BgmRetrieveResponse, summary="Retrieve local BGM files"
|
||||
)
|
||||
def get_bgm_list(request: Request):
|
||||
suffix = "*.mp3"
|
||||
song_dir = utils.song_dir()
|
||||
files = glob.glob(os.path.join(song_dir, suffix))
|
||||
bgm_list = []
|
||||
for file in files:
|
||||
filename = os.path.basename(file)
|
||||
bgm_list.append(
|
||||
{
|
||||
"name": filename,
|
||||
"size": os.path.getsize(file),
|
||||
# 只返回文件名,避免把服务器绝对路径暴露给调用方。
|
||||
# 服务端后续会把该文件名解析回 songs 白名单目录。
|
||||
"file": filename,
|
||||
}
|
||||
)
|
||||
response = {"files": bgm_list}
|
||||
return utils.get_response(200, response)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/musics",
|
||||
response_model=BgmUploadResponse,
|
||||
summary="Upload the BGM file to the songs directory",
|
||||
)
|
||||
def upload_bgm_file(request: Request, file: UploadFile = File(...)):
|
||||
request_id = base.get_task_id(request)
|
||||
safe_filename = _sanitize_upload_filename(file.filename, request_id)
|
||||
# check file ext
|
||||
if safe_filename.lower().endswith("mp3"):
|
||||
song_dir = utils.song_dir()
|
||||
save_path = os.path.join(song_dir, safe_filename)
|
||||
# save file
|
||||
with open(save_path, "wb+") as buffer:
|
||||
# If the file already exists, it will be overwritten
|
||||
file.file.seek(0)
|
||||
buffer.write(file.file.read())
|
||||
response = {"file": safe_filename}
|
||||
return utils.get_response(200, response)
|
||||
|
||||
raise HttpException(
|
||||
"", status_code=400, message=f"{request_id}: Only *.mp3 files can be uploaded"
|
||||
)
|
||||
|
||||
@router.get(
|
||||
"/video_materials", response_model=VideoMaterialRetrieveResponse, summary="Retrieve local video materials"
|
||||
)
|
||||
def get_video_materials_list(request: Request):
|
||||
allowed_suffixes = ("mp4", "mov", "avi", "flv", "mkv", "jpg", "jpeg", "png")
|
||||
local_videos_dir = utils.storage_dir("local_videos", create=True)
|
||||
files = []
|
||||
for suffix in allowed_suffixes:
|
||||
files.extend(glob.glob(os.path.join(local_videos_dir, f"*.{suffix}")))
|
||||
# 文件系统枚举顺序不稳定,直接返回会导致“顺序拼接”在不同机器或不同
|
||||
# 时刻表现不一致。这里统一按文件名排序,至少保证服务端返回顺序可预测。
|
||||
files.sort(key=lambda file_path: os.path.basename(file_path).lower())
|
||||
video_materials_list = []
|
||||
for file in files:
|
||||
filename = os.path.basename(file)
|
||||
video_materials_list.append(
|
||||
{
|
||||
"name": filename,
|
||||
"size": os.path.getsize(file),
|
||||
# 与 BGM 一样,只返回文件名;创建任务时再在 local_videos
|
||||
# 白名单目录内解析,避免 API 泄露宿主机绝对路径。
|
||||
"file": filename,
|
||||
}
|
||||
)
|
||||
response = {"files": video_materials_list}
|
||||
return utils.get_response(200, response)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/video_materials",
|
||||
response_model=VideoMaterialUploadResponse,
|
||||
summary="Upload the video material file to the local videos directory",
|
||||
)
|
||||
def upload_video_material_file(request: Request, file: UploadFile = File(...)):
|
||||
request_id = base.get_task_id(request)
|
||||
safe_filename = _sanitize_upload_filename(file.filename, request_id)
|
||||
# check file ext
|
||||
allowed_suffixes = ("mp4", "mov", "avi", "flv", "mkv", "jpg", "jpeg", "png")
|
||||
normalized_filename = safe_filename.lower()
|
||||
# 统一按小写扩展名校验,兼容 .MOV 这类大写后缀文件。
|
||||
if normalized_filename.endswith(allowed_suffixes):
|
||||
local_videos_dir = utils.storage_dir("local_videos", create=True)
|
||||
save_path = os.path.join(local_videos_dir, safe_filename)
|
||||
# save file
|
||||
with open(save_path, "wb+") as buffer:
|
||||
# If the file already exists, it will be overwritten
|
||||
file.file.seek(0)
|
||||
buffer.write(file.file.read())
|
||||
response = {"file": safe_filename}
|
||||
return utils.get_response(200, response)
|
||||
|
||||
raise HttpException(
|
||||
"", status_code=400, message=f"{request_id}: Only files with extensions {', '.join(allowed_suffixes)} can be uploaded"
|
||||
)
|
||||
|
||||
@router.get("/stream/{file_path:path}")
|
||||
async def stream_video(request: Request, file_path: str):
|
||||
request_id = base.get_task_id(request)
|
||||
tasks_dir = utils.task_dir()
|
||||
video_path = _resolve_path_within_directory(tasks_dir, file_path, request_id)
|
||||
range_header = request.headers.get("Range")
|
||||
video_size = os.path.getsize(video_path)
|
||||
start, end = 0, video_size - 1
|
||||
|
||||
length = video_size
|
||||
if range_header:
|
||||
range_ = range_header.split("bytes=")[1]
|
||||
start, end = [int(part) if part else None for part in range_.split("-")]
|
||||
if start is None:
|
||||
start = video_size - end
|
||||
end = video_size - 1
|
||||
if end is None:
|
||||
end = video_size - 1
|
||||
length = end - start + 1
|
||||
|
||||
def file_iterator(file_path, offset=0, bytes_to_read=None):
|
||||
with open(file_path, "rb") as f:
|
||||
f.seek(offset, os.SEEK_SET)
|
||||
remaining = bytes_to_read or video_size
|
||||
while remaining > 0:
|
||||
bytes_to_read = min(4096, remaining)
|
||||
data = f.read(bytes_to_read)
|
||||
if not data:
|
||||
break
|
||||
remaining -= len(data)
|
||||
yield data
|
||||
|
||||
response = StreamingResponse(
|
||||
file_iterator(video_path, start, length), media_type="video/mp4"
|
||||
)
|
||||
response.headers["Content-Range"] = f"bytes {start}-{end}/{video_size}"
|
||||
response.headers["Accept-Ranges"] = "bytes"
|
||||
response.headers["Content-Length"] = str(length)
|
||||
response.status_code = 206 # Partial Content
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@router.get("/download/{file_path:path}")
|
||||
async def download_video(request: Request, file_path: str):
|
||||
"""
|
||||
download video
|
||||
:param request: Request request
|
||||
:param file_path: video file path, eg: /cd1727ed-3473-42a2-a7da-4faafafec72b/final-1.mp4
|
||||
:return: video file
|
||||
"""
|
||||
request_id = base.get_task_id(request)
|
||||
tasks_dir = utils.task_dir()
|
||||
video_path = _resolve_path_within_directory(tasks_dir, file_path, request_id)
|
||||
file_path = pathlib.Path(video_path)
|
||||
filename = file_path.stem
|
||||
extension = file_path.suffix
|
||||
headers = {"Content-Disposition": f"attachment; filename={filename}{extension}"}
|
||||
return FileResponse(
|
||||
path=video_path,
|
||||
headers=headers,
|
||||
filename=f"{filename}{extension}",
|
||||
media_type=f"video/{extension[1:]}",
|
||||
)
|
||||
0
app/models/__init__.py
Normal file
0
app/models/__init__.py
Normal file
25
app/models/const.py
Normal file
25
app/models/const.py
Normal file
@@ -0,0 +1,25 @@
|
||||
PUNCTUATIONS = [
|
||||
"?",
|
||||
",",
|
||||
".",
|
||||
"、",
|
||||
";",
|
||||
":",
|
||||
"!",
|
||||
"…",
|
||||
"?",
|
||||
",",
|
||||
"。",
|
||||
"、",
|
||||
";",
|
||||
":",
|
||||
"!",
|
||||
"...",
|
||||
]
|
||||
|
||||
TASK_STATE_FAILED = -1
|
||||
TASK_STATE_COMPLETE = 1
|
||||
TASK_STATE_PROCESSING = 4
|
||||
|
||||
FILE_TYPE_VIDEOS = ["mp4", "mov", "mkv", "webm"]
|
||||
FILE_TYPE_IMAGES = ["jpg", "jpeg", "png", "bmp"]
|
||||
28
app/models/exception.py
Normal file
28
app/models/exception.py
Normal file
@@ -0,0 +1,28 @@
|
||||
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
|
||||
# Retrieve the exception stack trace information.
|
||||
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
|
||||
342
app/models/schema.py
Normal file
342
app/models/schema.py
Normal file
@@ -0,0 +1,342 @@
|
||||
import warnings
|
||||
from enum import Enum
|
||||
from typing import Any, List, Optional, Union
|
||||
|
||||
import pydantic
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.config import config
|
||||
|
||||
# 忽略 Pydantic 的特定警告
|
||||
warnings.filterwarnings(
|
||||
"ignore",
|
||||
category=UserWarning,
|
||||
message="Field name.*shadows an attribute in parent.*",
|
||||
)
|
||||
|
||||
|
||||
class VideoConcatMode(str, Enum):
|
||||
random = "random"
|
||||
sequential = "sequential"
|
||||
|
||||
|
||||
class VideoTransitionMode(str, Enum):
|
||||
none = None
|
||||
shuffle = "Shuffle"
|
||||
fade_in = "FadeIn"
|
||||
fade_out = "FadeOut"
|
||||
slide_in = "SlideIn"
|
||||
slide_out = "SlideOut"
|
||||
|
||||
|
||||
class VideoAspect(str, Enum):
|
||||
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
|
||||
|
||||
|
||||
class _Config:
|
||||
arbitrary_types_allowed = True
|
||||
|
||||
|
||||
@pydantic.dataclasses.dataclass(config=_Config)
|
||||
class MaterialInfo:
|
||||
provider: str = "pexels"
|
||||
url: str = ""
|
||||
duration: int = 0
|
||||
|
||||
|
||||
class VideoParams(BaseModel):
|
||||
"""
|
||||
{
|
||||
"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_script: str = "" # Script used to generate the video
|
||||
video_terms: Optional[str | list] = None # Keywords used to generate the video
|
||||
video_aspect: Optional[VideoAspect] = VideoAspect.portrait.value
|
||||
video_concat_mode: Optional[VideoConcatMode] = VideoConcatMode.random.value
|
||||
video_transition_mode: Optional[VideoTransitionMode] = None
|
||||
video_clip_duration: Optional[int] = 5
|
||||
video_count: Optional[int] = 1
|
||||
|
||||
video_source: Optional[str] = "pexels"
|
||||
video_materials: Optional[List[MaterialInfo]] = (
|
||||
None # Materials used to generate the video
|
||||
)
|
||||
|
||||
custom_audio_file: Optional[str] = None # Custom audio file path, will ignore video_script and disable subtitle
|
||||
video_language: Optional[str] = "" # auto detect
|
||||
|
||||
voice_name: Optional[str] = ""
|
||||
voice_volume: Optional[float] = 1.0
|
||||
voice_rate: Optional[float] = 1.0
|
||||
bgm_type: Optional[str] = "random"
|
||||
bgm_file: Optional[str] = ""
|
||||
bgm_volume: Optional[float] = 0.2
|
||||
|
||||
subtitle_enabled: Optional[bool] = True
|
||||
subtitle_position: Optional[str] = config.ui.get("subtitle_position", "bottom") # top, bottom, center, custom
|
||||
custom_position: float = config.ui.get("custom_position", 70.0)
|
||||
font_name: Optional[str] = "STHeitiMedium.ttc"
|
||||
text_fore_color: Optional[str] = "#FFFFFF"
|
||||
text_background_color: Union[bool, str] = True
|
||||
|
||||
font_size: int = 60
|
||||
stroke_color: Optional[str] = "#000000"
|
||||
stroke_width: float = 1.5
|
||||
n_threads: Optional[int] = 2
|
||||
paragraph_number: int = Field(default=1, ge=1, le=10)
|
||||
video_script_prompt: str = Field(default="", max_length=2000)
|
||||
custom_system_prompt: str = Field(default="", max_length=8000)
|
||||
|
||||
|
||||
class SubtitleRequest(BaseModel):
|
||||
video_script: str
|
||||
video_language: Optional[str] = ""
|
||||
voice_name: Optional[str] = "zh-CN-XiaoxiaoNeural-Female"
|
||||
voice_volume: Optional[float] = 1.0
|
||||
voice_rate: Optional[float] = 1.2
|
||||
bgm_type: Optional[str] = "random"
|
||||
bgm_file: Optional[str] = ""
|
||||
bgm_volume: Optional[float] = 0.2
|
||||
subtitle_position: Optional[str] = config.ui.get("subtitle_position", "bottom")
|
||||
font_name: Optional[str] = "STHeitiMedium.ttc"
|
||||
text_fore_color: Optional[str] = "#FFFFFF"
|
||||
text_background_color: Union[bool, str] = True
|
||||
font_size: int = 60
|
||||
stroke_color: Optional[str] = "#000000"
|
||||
stroke_width: float = 1.5
|
||||
video_source: Optional[str] = "local"
|
||||
subtitle_enabled: Optional[str] = "true"
|
||||
|
||||
|
||||
class AudioRequest(BaseModel):
|
||||
video_script: str
|
||||
video_language: Optional[str] = ""
|
||||
voice_name: Optional[str] = "zh-CN-XiaoxiaoNeural-Female"
|
||||
voice_volume: Optional[float] = 1.0
|
||||
voice_rate: Optional[float] = 1.2
|
||||
bgm_type: Optional[str] = "random"
|
||||
bgm_file: Optional[str] = ""
|
||||
bgm_volume: Optional[float] = 0.2
|
||||
video_source: Optional[str] = "local"
|
||||
|
||||
|
||||
class VideoScriptParams:
|
||||
"""
|
||||
{
|
||||
"video_subject": "春天的花海",
|
||||
"video_language": "",
|
||||
"paragraph_number": 1,
|
||||
"video_script_prompt": "",
|
||||
"custom_system_prompt": ""
|
||||
}
|
||||
"""
|
||||
|
||||
video_subject: Optional[str] = "春天的花海"
|
||||
video_language: Optional[str] = ""
|
||||
paragraph_number: int = Field(default=1, ge=1, le=10)
|
||||
video_script_prompt: str = Field(default="", max_length=2000)
|
||||
custom_system_prompt: str = Field(default="", max_length=8000)
|
||||
|
||||
|
||||
class VideoTermsParams:
|
||||
"""
|
||||
{
|
||||
"video_subject": "",
|
||||
"video_script": "",
|
||||
"amount": 5
|
||||
}
|
||||
"""
|
||||
|
||||
video_subject: Optional[str] = "春天的花海"
|
||||
video_script: Optional[str] = (
|
||||
"春天的花海,如诗如画般展现在眼前。万物复苏的季节里,大地披上了一袭绚丽多彩的盛装。金黄的迎春、粉嫩的樱花、洁白的梨花、艳丽的郁金香……"
|
||||
)
|
||||
amount: Optional[int] = 5
|
||||
|
||||
|
||||
class BaseResponse(BaseModel):
|
||||
status: int = 200
|
||||
message: Optional[str] = "success"
|
||||
data: Any = None
|
||||
|
||||
|
||||
class TaskVideoRequest(VideoParams, BaseModel):
|
||||
pass
|
||||
|
||||
|
||||
class TaskQueryRequest(BaseModel):
|
||||
pass
|
||||
|
||||
|
||||
class VideoScriptRequest(VideoScriptParams, BaseModel):
|
||||
pass
|
||||
|
||||
|
||||
class VideoTermsRequest(VideoTermsParams, BaseModel):
|
||||
pass
|
||||
|
||||
|
||||
######################################################################################################
|
||||
######################################################################################################
|
||||
######################################################################################################
|
||||
######################################################################################################
|
||||
class TaskResponse(BaseResponse):
|
||||
class TaskResponseData(BaseModel):
|
||||
task_id: str
|
||||
|
||||
data: TaskResponseData
|
||||
|
||||
class Config:
|
||||
json_schema_extra = {
|
||||
"example": {
|
||||
"status": 200,
|
||||
"message": "success",
|
||||
"data": {"task_id": "6c85c8cc-a77a-42b9-bc30-947815aa0558"},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class TaskQueryResponse(BaseResponse):
|
||||
class Config:
|
||||
json_schema_extra = {
|
||||
"example": {
|
||||
"status": 200,
|
||||
"message": "success",
|
||||
"data": {
|
||||
"state": 1,
|
||||
"progress": 100,
|
||||
"videos": [
|
||||
"http://127.0.0.1:8080/tasks/6c85c8cc-a77a-42b9-bc30-947815aa0558/final-1.mp4"
|
||||
],
|
||||
"combined_videos": [
|
||||
"http://127.0.0.1:8080/tasks/6c85c8cc-a77a-42b9-bc30-947815aa0558/combined-1.mp4"
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class TaskDeletionResponse(BaseResponse):
|
||||
class Config:
|
||||
json_schema_extra = {
|
||||
"example": {
|
||||
"status": 200,
|
||||
"message": "success",
|
||||
"data": {
|
||||
"state": 1,
|
||||
"progress": 100,
|
||||
"videos": [
|
||||
"http://127.0.0.1:8080/tasks/6c85c8cc-a77a-42b9-bc30-947815aa0558/final-1.mp4"
|
||||
],
|
||||
"combined_videos": [
|
||||
"http://127.0.0.1:8080/tasks/6c85c8cc-a77a-42b9-bc30-947815aa0558/combined-1.mp4"
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class VideoScriptResponse(BaseResponse):
|
||||
class Config:
|
||||
json_schema_extra = {
|
||||
"example": {
|
||||
"status": 200,
|
||||
"message": "success",
|
||||
"data": {
|
||||
"video_script": "春天的花海,是大自然的一幅美丽画卷。在这个季节里,大地复苏,万物生长,花朵争相绽放,形成了一片五彩斑斓的花海..."
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class VideoTermsResponse(BaseResponse):
|
||||
class Config:
|
||||
json_schema_extra = {
|
||||
"example": {
|
||||
"status": 200,
|
||||
"message": "success",
|
||||
"data": {"video_terms": ["sky", "tree"]},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class BgmRetrieveResponse(BaseResponse):
|
||||
class Config:
|
||||
json_schema_extra = {
|
||||
"example": {
|
||||
"status": 200,
|
||||
"message": "success",
|
||||
"data": {
|
||||
"files": [
|
||||
{
|
||||
"name": "output013.mp3",
|
||||
"size": 1891269,
|
||||
"file": "/MoneyPrinterTurbo/resource/songs/output013.mp3",
|
||||
}
|
||||
]
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class BgmUploadResponse(BaseResponse):
|
||||
class Config:
|
||||
json_schema_extra = {
|
||||
"example": {
|
||||
"status": 200,
|
||||
"message": "success",
|
||||
"data": {"file": "/MoneyPrinterTurbo/resource/songs/example.mp3"},
|
||||
},
|
||||
}
|
||||
|
||||
class VideoMaterialRetrieveResponse(BaseResponse):
|
||||
class Config:
|
||||
json_schema_extra = {
|
||||
"example": {
|
||||
"status": 200,
|
||||
"message": "success",
|
||||
"data": {
|
||||
"files": [
|
||||
{
|
||||
"name": "example.mp4",
|
||||
"size": 12345678,
|
||||
"file": "/MoneyPrinterTurbo/resource/videos/example.mp4",
|
||||
}
|
||||
]
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
class VideoMaterialUploadResponse(BaseResponse):
|
||||
class Config:
|
||||
json_schema_extra = {
|
||||
"example": {
|
||||
"status": 200,
|
||||
"message": "success",
|
||||
"data": {
|
||||
"file": "/MoneyPrinterTurbo/resource/videos/example.mp4",
|
||||
},
|
||||
},
|
||||
}
|
||||
17
app/router.py
Normal file
17
app/router.py
Normal file
@@ -0,0 +1,17 @@
|
||||
"""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 llm, video
|
||||
|
||||
root_api_router = APIRouter()
|
||||
# v1
|
||||
root_api_router.include_router(video.router)
|
||||
root_api_router.include_router(llm.router)
|
||||
0
app/services/__init__.py
Normal file
0
app/services/__init__.py
Normal file
714
app/services/llm.py
Normal file
714
app/services/llm.py
Normal file
@@ -0,0 +1,714 @@
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import requests
|
||||
from typing import List
|
||||
|
||||
from loguru import logger
|
||||
from openai import AzureOpenAI, OpenAI
|
||||
from openai.types.chat import ChatCompletion
|
||||
|
||||
from app.config import config
|
||||
|
||||
_max_retries = 5
|
||||
_DEFAULT_GEMINI_MODEL = "gemini-2.5-flash"
|
||||
_DEPRECATED_GEMINI_MODELS = {"gemini-pro", "gemini-1.0-pro"}
|
||||
MIN_SCRIPT_PARAGRAPH_NUMBER = 1
|
||||
MAX_SCRIPT_PARAGRAPH_NUMBER = 10
|
||||
MAX_SCRIPT_PROMPT_LENGTH = 2000
|
||||
MAX_SCRIPT_SYSTEM_PROMPT_LENGTH = 8000
|
||||
|
||||
DEFAULT_SCRIPT_SYSTEM_PROMPT = """
|
||||
# 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.
|
||||
8. respond in the same language as the video subject.
|
||||
""".strip()
|
||||
|
||||
|
||||
def _normalize_text_response(content, llm_provider: str) -> str:
|
||||
# 不同 LLM SDK 在异常或被拦截场景下,可能返回 None、空字符串,
|
||||
# 甚至返回非字符串对象。这里统一做兜底校验,避免后续直接调用
|
||||
# `.replace()` 时抛出 `NoneType` 之类的属性错误。
|
||||
if content is None:
|
||||
raise ValueError(f"[{llm_provider}] returned empty text content")
|
||||
|
||||
if not isinstance(content, str):
|
||||
raise TypeError(
|
||||
f"[{llm_provider}] returned non-text content: {type(content).__name__}"
|
||||
)
|
||||
|
||||
content = content.strip()
|
||||
if not content:
|
||||
raise ValueError(f"[{llm_provider}] returned empty text content")
|
||||
|
||||
return content.replace("\n", "")
|
||||
|
||||
|
||||
def _extract_chat_completion_text(response, llm_provider: str) -> str:
|
||||
# OpenAI 兼容接口在异常场景下,可能返回没有 choices、
|
||||
# 或者 choices/message/content 为空的响应对象。
|
||||
# 这里统一做结构校验,避免出现 `NoneType is not subscriptable`
|
||||
# 这类底层属性访问错误。
|
||||
choices = getattr(response, "choices", None)
|
||||
if not choices:
|
||||
raise ValueError(f"[{llm_provider}] returned empty choices")
|
||||
|
||||
first_choice = choices[0]
|
||||
message = getattr(first_choice, "message", None)
|
||||
if message is None:
|
||||
raise ValueError(f"[{llm_provider}] returned empty message")
|
||||
|
||||
content = getattr(message, "content", None)
|
||||
return _normalize_text_response(content, llm_provider)
|
||||
|
||||
|
||||
def _generate_response(prompt: str) -> str:
|
||||
try:
|
||||
content = ""
|
||||
llm_provider = config.app.get("llm_provider", "openai")
|
||||
logger.info(f"llm provider: {llm_provider}")
|
||||
if llm_provider == "g4f":
|
||||
if not config.app.get("enable_g4f", False):
|
||||
raise ValueError(
|
||||
"g4f provider is disabled by default because it relies on "
|
||||
"reverse-engineered third-party endpoints. Set enable_g4f=true "
|
||||
"in config.toml only if you understand and accept the security, "
|
||||
"reliability, and legal risks."
|
||||
)
|
||||
|
||||
logger.warning(
|
||||
"g4f provider is enabled. This provider may be unstable and carries "
|
||||
"supply-chain and terms-of-service risks. Prefer official providers, "
|
||||
"OpenAI-compatible APIs, LiteLLM, Ollama, or local inference for production."
|
||||
)
|
||||
try:
|
||||
import g4f
|
||||
except ImportError as e:
|
||||
raise ValueError(
|
||||
"g4f package is not installed by default. Install the optional "
|
||||
"dependency with `uv sync --extra g4f` only if you understand "
|
||||
"and accept the provider risks."
|
||||
) from e
|
||||
|
||||
model_name = config.app.get("g4f_model_name", "")
|
||||
if not model_name:
|
||||
model_name = "gpt-3.5-turbo-16k-0613"
|
||||
content = g4f.ChatCompletion.create(
|
||||
model=model_name,
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
)
|
||||
else:
|
||||
api_version = "" # for azure
|
||||
if llm_provider == "moonshot":
|
||||
api_key = config.app.get("moonshot_api_key")
|
||||
model_name = config.app.get("moonshot_model_name")
|
||||
base_url = "https://api.moonshot.cn/v1"
|
||||
elif llm_provider == "ollama":
|
||||
# api_key = config.app.get("openai_api_key")
|
||||
api_key = "ollama" # any string works but you are required to have one
|
||||
model_name = config.app.get("ollama_model_name")
|
||||
base_url = config.app.get("ollama_base_url", "")
|
||||
if not base_url:
|
||||
base_url = config.get_default_ollama_base_url()
|
||||
elif llm_provider == "openai":
|
||||
api_key = config.app.get("openai_api_key")
|
||||
model_name = config.app.get("openai_model_name")
|
||||
base_url = config.app.get("openai_base_url", "")
|
||||
if not base_url:
|
||||
base_url = "https://api.openai.com/v1"
|
||||
elif llm_provider == "oneapi":
|
||||
api_key = config.app.get("oneapi_api_key")
|
||||
model_name = config.app.get("oneapi_model_name")
|
||||
base_url = config.app.get("oneapi_base_url", "")
|
||||
elif llm_provider == "azure":
|
||||
api_key = config.app.get("azure_api_key")
|
||||
model_name = config.app.get("azure_model_name")
|
||||
base_url = config.app.get("azure_base_url", "")
|
||||
api_version = config.app.get("azure_api_version", "2024-02-15-preview")
|
||||
elif llm_provider == "gemini":
|
||||
api_key = config.app.get("gemini_api_key")
|
||||
model_name = config.app.get("gemini_model_name")
|
||||
base_url = config.app.get("gemini_base_url", "")
|
||||
# Gemini 旧模型名已经陆续下线,这里自动兼容历史配置,
|
||||
# 避免用户沿用旧值时直接收到 404。
|
||||
if not model_name:
|
||||
model_name = _DEFAULT_GEMINI_MODEL
|
||||
elif model_name in _DEPRECATED_GEMINI_MODELS:
|
||||
logger.warning(
|
||||
f"gemini model '{model_name}' is deprecated, fallback to '{_DEFAULT_GEMINI_MODEL}'"
|
||||
)
|
||||
model_name = _DEFAULT_GEMINI_MODEL
|
||||
elif llm_provider == "grok":
|
||||
api_key = config.app.get("grok_api_key")
|
||||
model_name = config.app.get("grok_model_name")
|
||||
base_url = config.app.get("grok_base_url", "")
|
||||
if not base_url:
|
||||
base_url = "https://api.x.ai/v1"
|
||||
elif llm_provider == "qwen":
|
||||
api_key = config.app.get("qwen_api_key")
|
||||
model_name = config.app.get("qwen_model_name")
|
||||
base_url = "***"
|
||||
elif llm_provider == "cloudflare":
|
||||
api_key = config.app.get("cloudflare_api_key")
|
||||
model_name = config.app.get("cloudflare_model_name")
|
||||
account_id = config.app.get("cloudflare_account_id")
|
||||
base_url = "***"
|
||||
elif llm_provider == "minimax":
|
||||
api_key = config.app.get("minimax_api_key")
|
||||
model_name = config.app.get("minimax_model_name")
|
||||
base_url = config.app.get("minimax_base_url", "")
|
||||
if not base_url:
|
||||
base_url = "https://api.minimax.io/v1"
|
||||
elif llm_provider == "mimo":
|
||||
api_key = config.app.get("mimo_api_key")
|
||||
model_name = config.app.get("mimo_model_name")
|
||||
base_url = config.app.get("mimo_base_url", "")
|
||||
# Xiaomi MiMo 官方文档说明其兼容 OpenAI Chat Completions 协议。
|
||||
# 这里使用独立 provider 保存默认地址和模型名,用户不用把 MiMo
|
||||
# 当作 OpenAI 自定义 base_url 配置,也便于后续继续接入 MiMo
|
||||
# 多模态或 TTS 能力时保持边界清晰。
|
||||
if not base_url:
|
||||
base_url = "https://api.xiaomimimo.com/v1"
|
||||
if not model_name:
|
||||
model_name = "mimo-v2.5-pro"
|
||||
elif llm_provider == "deepseek":
|
||||
api_key = config.app.get("deepseek_api_key")
|
||||
model_name = config.app.get("deepseek_model_name")
|
||||
base_url = config.app.get("deepseek_base_url")
|
||||
if not base_url:
|
||||
base_url = "https://api.deepseek.com"
|
||||
elif llm_provider == "modelscope":
|
||||
api_key = config.app.get("modelscope_api_key")
|
||||
model_name = config.app.get("modelscope_model_name")
|
||||
base_url = config.app.get("modelscope_base_url")
|
||||
if not base_url:
|
||||
base_url = "https://api-inference.modelscope.cn/v1/"
|
||||
elif llm_provider == "ernie":
|
||||
api_key = config.app.get("ernie_api_key")
|
||||
secret_key = config.app.get("ernie_secret_key")
|
||||
base_url = config.app.get("ernie_base_url")
|
||||
model_name = "***"
|
||||
if not secret_key:
|
||||
raise ValueError(
|
||||
f"{llm_provider}: secret_key is not set, please set it in the config.toml file."
|
||||
)
|
||||
elif llm_provider == "pollinations":
|
||||
try:
|
||||
base_url = config.app.get("pollinations_base_url", "")
|
||||
if not base_url:
|
||||
base_url = "https://text.pollinations.ai/openai"
|
||||
model_name = config.app.get("pollinations_model_name", "openai-fast")
|
||||
|
||||
# Prepare the payload
|
||||
payload = {
|
||||
"model": model_name,
|
||||
"messages": [
|
||||
{"role": "user", "content": prompt}
|
||||
],
|
||||
"seed": 101 # Optional but helps with reproducibility
|
||||
}
|
||||
|
||||
# Optional parameters if configured
|
||||
if config.app.get("pollinations_private"):
|
||||
payload["private"] = True
|
||||
if config.app.get("pollinations_referrer"):
|
||||
payload["referrer"] = config.app.get("pollinations_referrer")
|
||||
|
||||
headers = {
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
# Make the API request
|
||||
response = requests.post(base_url, headers=headers, json=payload)
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
|
||||
if result and "choices" in result and len(result["choices"]) > 0:
|
||||
content = result["choices"][0]["message"]["content"]
|
||||
return _normalize_text_response(content, llm_provider)
|
||||
else:
|
||||
raise Exception(f"[{llm_provider}] returned an invalid response format")
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
raise Exception(f"[{llm_provider}] request failed: {str(e)}")
|
||||
except Exception as e:
|
||||
raise Exception(f"[{llm_provider}] error: {str(e)}")
|
||||
|
||||
elif llm_provider == "litellm":
|
||||
model_name = config.app.get("litellm_model_name")
|
||||
|
||||
if llm_provider not in ["pollinations", "ollama", "litellm"]: # Skip validation for providers that don't require API key
|
||||
if not api_key:
|
||||
raise ValueError(
|
||||
f"{llm_provider}: api_key is not set, please set it in the config.toml file."
|
||||
)
|
||||
if not model_name:
|
||||
raise ValueError(
|
||||
f"{llm_provider}: model_name is not set, please set it in the config.toml file."
|
||||
)
|
||||
if not base_url and llm_provider not in ["gemini"]:
|
||||
raise ValueError(
|
||||
f"{llm_provider}: base_url is not set, please set it in the config.toml file."
|
||||
)
|
||||
|
||||
if llm_provider == "qwen":
|
||||
import dashscope
|
||||
from dashscope.api_entities.dashscope_response import GenerationResponse
|
||||
|
||||
dashscope.api_key = api_key
|
||||
response = dashscope.Generation.call(
|
||||
model=model_name, messages=[{"role": "user", "content": prompt}]
|
||||
)
|
||||
if response:
|
||||
if isinstance(response, GenerationResponse):
|
||||
status_code = response.status_code
|
||||
if status_code != 200:
|
||||
raise Exception(
|
||||
f'[{llm_provider}] returned an error response: "{response}"'
|
||||
)
|
||||
|
||||
content = response["output"]["text"]
|
||||
return content.replace("\n", "")
|
||||
else:
|
||||
raise Exception(
|
||||
f'[{llm_provider}] returned an invalid response: "{response}"'
|
||||
)
|
||||
else:
|
||||
raise Exception(f"[{llm_provider}] returned an empty response")
|
||||
|
||||
if llm_provider == "gemini":
|
||||
import google.generativeai as genai
|
||||
|
||||
if not base_url:
|
||||
genai.configure(api_key=api_key, transport="rest")
|
||||
else:
|
||||
genai.configure(api_key=api_key, transport="rest", client_options={'api_endpoint': base_url})
|
||||
|
||||
generation_config = {
|
||||
"temperature": 0.5,
|
||||
"top_p": 1,
|
||||
"top_k": 1,
|
||||
"max_output_tokens": 2048,
|
||||
}
|
||||
|
||||
safety_settings = [
|
||||
{
|
||||
"category": "HARM_CATEGORY_HARASSMENT",
|
||||
"threshold": "BLOCK_ONLY_HIGH",
|
||||
},
|
||||
{
|
||||
"category": "HARM_CATEGORY_HATE_SPEECH",
|
||||
"threshold": "BLOCK_ONLY_HIGH",
|
||||
},
|
||||
{
|
||||
"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT",
|
||||
"threshold": "BLOCK_ONLY_HIGH",
|
||||
},
|
||||
{
|
||||
"category": "HARM_CATEGORY_DANGEROUS_CONTENT",
|
||||
"threshold": "BLOCK_ONLY_HIGH",
|
||||
},
|
||||
]
|
||||
|
||||
model = genai.GenerativeModel(
|
||||
model_name=model_name,
|
||||
generation_config=generation_config,
|
||||
safety_settings=safety_settings,
|
||||
)
|
||||
|
||||
try:
|
||||
response = model.generate_content(prompt)
|
||||
candidates = response.candidates
|
||||
generated_text = candidates[0].content.parts[0].text
|
||||
except (AttributeError, IndexError) as e:
|
||||
logger.warning(
|
||||
f"gemini returned invalid response content: {str(e)}"
|
||||
)
|
||||
raise ValueError(
|
||||
f"[{llm_provider}] returned invalid response content"
|
||||
)
|
||||
|
||||
return _normalize_text_response(generated_text, llm_provider)
|
||||
|
||||
if llm_provider == "cloudflare":
|
||||
response = requests.post(
|
||||
f"https://api.cloudflare.com/client/v4/accounts/{account_id}/ai/run/{model_name}",
|
||||
headers={"Authorization": f"Bearer {api_key}"},
|
||||
json={
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
"content": "You are a friendly assistant",
|
||||
},
|
||||
{"role": "user", "content": prompt},
|
||||
]
|
||||
},
|
||||
)
|
||||
result = response.json()
|
||||
logger.info(result)
|
||||
return _normalize_text_response(result["result"]["response"], llm_provider)
|
||||
|
||||
if llm_provider == "ernie":
|
||||
response = requests.post(
|
||||
"https://aip.baidubce.com/oauth/2.0/token",
|
||||
params={
|
||||
"grant_type": "client_credentials",
|
||||
"client_id": api_key,
|
||||
"client_secret": secret_key,
|
||||
}
|
||||
)
|
||||
access_token = response.json().get("access_token")
|
||||
url = f"{base_url}?access_token={access_token}"
|
||||
|
||||
payload = json.dumps(
|
||||
{
|
||||
"messages": [{"role": "user", "content": prompt}],
|
||||
"temperature": 0.5,
|
||||
"top_p": 0.8,
|
||||
"penalty_score": 1,
|
||||
"disable_search": False,
|
||||
"enable_citation": False,
|
||||
"response_format": "text",
|
||||
}
|
||||
)
|
||||
headers = {"Content-Type": "application/json"}
|
||||
|
||||
response = requests.request(
|
||||
"POST", url, headers=headers, data=payload
|
||||
).json()
|
||||
return _normalize_text_response(response.get("result"), llm_provider)
|
||||
|
||||
if llm_provider == "litellm":
|
||||
import litellm
|
||||
|
||||
if not model_name:
|
||||
raise ValueError(
|
||||
f"{llm_provider}: model_name is not set, please set it in the config.toml file."
|
||||
)
|
||||
|
||||
response = litellm.completion(
|
||||
model=model_name,
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
drop_params=True,
|
||||
)
|
||||
|
||||
if not response:
|
||||
raise ValueError(f"[{llm_provider}] returned empty response")
|
||||
if not getattr(response, "choices", None):
|
||||
raise ValueError(f"[{llm_provider}] returned empty response")
|
||||
|
||||
return _extract_chat_completion_text(response, llm_provider)
|
||||
|
||||
if llm_provider == "azure":
|
||||
# Azure OpenAI SDK 使用 `azure_endpoint` 和 `api_version` 生成专用请求地址,
|
||||
# 不能继续复用下面普通 OpenAI-compatible 的 `base_url` 初始化逻辑。
|
||||
# 这里在 Azure 分支内完成请求并立即返回,避免客户端被后续 fallback
|
||||
# 覆盖,导致用户配置的 Azure 凭证通过校验但实际请求没有被使用。
|
||||
logger.info(f"requesting azure chat completion, model: {model_name}")
|
||||
client = AzureOpenAI(
|
||||
api_key=api_key,
|
||||
api_version=api_version,
|
||||
azure_endpoint=base_url,
|
||||
)
|
||||
response = client.chat.completions.create(
|
||||
model=model_name, messages=[{"role": "user", "content": prompt}]
|
||||
)
|
||||
if response:
|
||||
if isinstance(response, ChatCompletion):
|
||||
return _extract_chat_completion_text(response, llm_provider)
|
||||
else:
|
||||
raise Exception(
|
||||
f'[{llm_provider}] returned an invalid response: "{response}", please check your network '
|
||||
f"connection and try again."
|
||||
)
|
||||
else:
|
||||
raise Exception(
|
||||
f"[{llm_provider}] returned an empty response, please check your network connection and try again."
|
||||
)
|
||||
|
||||
if llm_provider == "modelscope":
|
||||
content = ''
|
||||
client = OpenAI(
|
||||
api_key=api_key,
|
||||
base_url=base_url,
|
||||
)
|
||||
response = client.chat.completions.create(
|
||||
model=model_name,
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
extra_body={"enable_thinking": False},
|
||||
stream=True
|
||||
)
|
||||
if response:
|
||||
for chunk in response:
|
||||
if not chunk.choices:
|
||||
continue
|
||||
delta = chunk.choices[0].delta
|
||||
if delta and delta.content:
|
||||
content += delta.content
|
||||
|
||||
if not content.strip():
|
||||
raise ValueError("Empty content in stream response")
|
||||
|
||||
return _normalize_text_response(content, llm_provider)
|
||||
else:
|
||||
raise Exception(f"[{llm_provider}] returned an empty response")
|
||||
|
||||
else:
|
||||
client = OpenAI(
|
||||
api_key=api_key,
|
||||
base_url=base_url,
|
||||
)
|
||||
|
||||
response = client.chat.completions.create(
|
||||
model=model_name, messages=[{"role": "user", "content": prompt}]
|
||||
)
|
||||
if response:
|
||||
if isinstance(response, ChatCompletion):
|
||||
return _extract_chat_completion_text(response, llm_provider)
|
||||
else:
|
||||
raise Exception(
|
||||
f'[{llm_provider}] returned an invalid response: "{response}", please check your network '
|
||||
f"connection and try again."
|
||||
)
|
||||
else:
|
||||
raise Exception(
|
||||
f"[{llm_provider}] returned an empty response, please check your network connection and try again."
|
||||
)
|
||||
|
||||
return _normalize_text_response(content, llm_provider)
|
||||
except Exception as e:
|
||||
return f"Error: {str(e)}"
|
||||
|
||||
|
||||
def _limit_script_text(text: str | None, max_length: int, field_name: str) -> str:
|
||||
value = (text or "").strip()
|
||||
if len(value) <= max_length:
|
||||
return value
|
||||
|
||||
# API 层已经用 Pydantic 做长度校验;这里继续兜底,是为了保护
|
||||
# WebUI 或内部服务直接调用 generate_script 时不会把超长提示词发送给模型,
|
||||
# 避免 token 成本异常和请求失败。
|
||||
logger.warning(
|
||||
f"{field_name} is too long and will be truncated to {max_length} characters."
|
||||
)
|
||||
return value[:max_length]
|
||||
|
||||
|
||||
def _normalize_script_paragraph_number(paragraph_number: int | None) -> int:
|
||||
try:
|
||||
value = int(paragraph_number or MIN_SCRIPT_PARAGRAPH_NUMBER)
|
||||
except (TypeError, ValueError):
|
||||
value = MIN_SCRIPT_PARAGRAPH_NUMBER
|
||||
|
||||
if value < MIN_SCRIPT_PARAGRAPH_NUMBER or value > MAX_SCRIPT_PARAGRAPH_NUMBER:
|
||||
# WebUI 和 API 都会限制范围;这里兜底处理内部调用,避免异常参数直接扩大
|
||||
# LLM 生成成本或生成空结果。
|
||||
logger.warning(
|
||||
"script paragraph_number is out of range and will be clamped: "
|
||||
f"{value}"
|
||||
)
|
||||
return max(MIN_SCRIPT_PARAGRAPH_NUMBER, min(value, MAX_SCRIPT_PARAGRAPH_NUMBER))
|
||||
|
||||
return value
|
||||
|
||||
|
||||
def build_script_prompt(
|
||||
video_subject: str,
|
||||
language: str = "",
|
||||
paragraph_number: int = 1,
|
||||
video_script_prompt: str = "",
|
||||
custom_system_prompt: str = "",
|
||||
) -> str:
|
||||
paragraph_number = _normalize_script_paragraph_number(paragraph_number)
|
||||
video_script_prompt = _limit_script_text(
|
||||
video_script_prompt, MAX_SCRIPT_PROMPT_LENGTH, "video_script_prompt"
|
||||
)
|
||||
custom_system_prompt = _limit_script_text(
|
||||
custom_system_prompt, MAX_SCRIPT_SYSTEM_PROMPT_LENGTH, "custom_system_prompt"
|
||||
)
|
||||
|
||||
# 将“脚本生成规则”和“运行时上下文”分开拼接。这样高级用户即使覆盖默认
|
||||
# system prompt,也不会漏掉视频主题、语言、段落数这些每次生成都必须带上的参数。
|
||||
prompt = custom_system_prompt or DEFAULT_SCRIPT_SYSTEM_PROMPT
|
||||
prompt += f"""
|
||||
|
||||
# Initialization:
|
||||
- video subject: {video_subject}
|
||||
- number of paragraphs: {paragraph_number}
|
||||
""".rstrip()
|
||||
if language:
|
||||
prompt += f"\n- language: {language}"
|
||||
if video_script_prompt:
|
||||
prompt += f"""
|
||||
|
||||
# Additional User Requirements:
|
||||
{video_script_prompt}
|
||||
""".rstrip()
|
||||
|
||||
return prompt
|
||||
|
||||
|
||||
def generate_script(
|
||||
video_subject: str,
|
||||
language: str = "",
|
||||
paragraph_number: int = 1,
|
||||
video_script_prompt: str = "",
|
||||
custom_system_prompt: str = "",
|
||||
) -> str:
|
||||
paragraph_number = _normalize_script_paragraph_number(paragraph_number)
|
||||
video_script_prompt = _limit_script_text(
|
||||
video_script_prompt, MAX_SCRIPT_PROMPT_LENGTH, "video_script_prompt"
|
||||
)
|
||||
custom_system_prompt = _limit_script_text(
|
||||
custom_system_prompt, MAX_SCRIPT_SYSTEM_PROMPT_LENGTH, "custom_system_prompt"
|
||||
)
|
||||
prompt = build_script_prompt(
|
||||
video_subject=video_subject,
|
||||
language=language,
|
||||
paragraph_number=paragraph_number,
|
||||
video_script_prompt=video_script_prompt,
|
||||
custom_system_prompt=custom_system_prompt,
|
||||
)
|
||||
final_script = ""
|
||||
logger.info(
|
||||
"generating video script: "
|
||||
f"subject={video_subject}, paragraph_number={paragraph_number}, "
|
||||
f"has_custom_prompt={bool(video_script_prompt.strip())}, "
|
||||
f"has_custom_system_prompt={bool(custom_system_prompt.strip())}"
|
||||
)
|
||||
|
||||
def format_response(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
|
||||
return "\n\n".join(paragraphs)
|
||||
|
||||
for i in range(_max_retries):
|
||||
try:
|
||||
response = _generate_response(prompt=prompt)
|
||||
if response:
|
||||
final_script = format_response(response)
|
||||
else:
|
||||
logging.error("gpt returned an empty response")
|
||||
|
||||
# g4f may return an error message
|
||||
if final_script and "当日额度已消耗完" in final_script:
|
||||
raise ValueError(final_script)
|
||||
|
||||
if final_script:
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"failed to generate script: {e}")
|
||||
|
||||
if i < _max_retries:
|
||||
logger.warning(f"failed to generate video script, trying again... {i + 1}")
|
||||
if "Error: " in final_script:
|
||||
logger.error(f"failed to generate video script: {final_script}")
|
||||
else:
|
||||
logger.success(f"completed: \n{final_script}")
|
||||
return final_script.strip()
|
||||
|
||||
|
||||
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}
|
||||
|
||||
Please note that you must use English for generating video search terms; Chinese is not accepted.
|
||||
""".strip()
|
||||
|
||||
logger.info(f"subject: {video_subject}")
|
||||
|
||||
search_terms = []
|
||||
response = ""
|
||||
for i in range(_max_retries):
|
||||
try:
|
||||
response = _generate_response(prompt)
|
||||
if "Error: " in response:
|
||||
logger.error(f"failed to generate video script: {response}")
|
||||
return response
|
||||
search_terms = json.loads(response)
|
||||
if not isinstance(search_terms, list) or not all(
|
||||
isinstance(term, str) for term in search_terms
|
||||
):
|
||||
logger.error("response is not a list of strings.")
|
||||
continue
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"failed to generate video terms: {str(e)}")
|
||||
if response:
|
||||
match = re.search(r"\[.*]", response)
|
||||
if match:
|
||||
try:
|
||||
search_terms = json.loads(match.group())
|
||||
except Exception as e:
|
||||
# 这里保留重试流程,但必须记录 LLM 返回的非标准 JSON,
|
||||
# 否则后续排查搜索词为空时无法定位
|
||||
# 是模型格式问题还是解析逻辑问题。
|
||||
logger.warning(f"failed to generate video terms: {str(e)}")
|
||||
|
||||
if search_terms and len(search_terms) > 0:
|
||||
break
|
||||
if i < _max_retries:
|
||||
logger.warning(f"failed to generate video terms, trying again... {i + 1}")
|
||||
|
||||
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)
|
||||
|
||||
299
app/services/material.py
Normal file
299
app/services/material.py
Normal file
@@ -0,0 +1,299 @@
|
||||
import os
|
||||
import random
|
||||
import threading
|
||||
from typing import List
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import requests
|
||||
from loguru import logger
|
||||
from moviepy.video.io.VideoFileClip import VideoFileClip
|
||||
|
||||
from app.config import config
|
||||
from app.models.schema import MaterialInfo, VideoAspect, VideoConcatMode
|
||||
from app.utils import utils
|
||||
|
||||
# Thread-safe counter for API key rotation
|
||||
_api_key_counter = 0
|
||||
_api_key_lock = threading.Lock()
|
||||
|
||||
|
||||
def _get_tls_verify() -> bool:
|
||||
# 默认开启 TLS 证书校验,防止素材搜索和下载过程被中间人篡改。
|
||||
# 仅在企业代理、自签证书等明确需要的场景下,允许用户通过
|
||||
# `config.toml` 显式设置 `tls_verify = false` 临时关闭。
|
||||
tls_verify = config.app.get("tls_verify", True)
|
||||
if isinstance(tls_verify, str):
|
||||
tls_verify = tls_verify.strip().lower() not in ("0", "false", "no", "off")
|
||||
|
||||
if not tls_verify:
|
||||
logger.warning(
|
||||
"TLS certificate verification is disabled by config.app.tls_verify=false. "
|
||||
"Only use this in trusted proxy environments."
|
||||
)
|
||||
|
||||
return bool(tls_verify)
|
||||
|
||||
|
||||
def get_api_key(cfg_key: str):
|
||||
api_keys = config.app.get(cfg_key)
|
||||
if not api_keys:
|
||||
raise ValueError(
|
||||
f"\n\n##### {cfg_key} is not set #####\n\nPlease set it in the config.toml file: {config.config_file}\n\n"
|
||||
f"{utils.to_json(config.app)}"
|
||||
)
|
||||
|
||||
# if only one key is provided, return it
|
||||
if isinstance(api_keys, str):
|
||||
return api_keys
|
||||
|
||||
global _api_key_counter
|
||||
with _api_key_lock:
|
||||
_api_key_counter += 1
|
||||
return api_keys[_api_key_counter % len(api_keys)]
|
||||
|
||||
|
||||
def search_videos_pexels(
|
||||
search_term: str,
|
||||
minimum_duration: int,
|
||||
video_aspect: VideoAspect = VideoAspect.portrait,
|
||||
) -> List[MaterialInfo]:
|
||||
aspect = VideoAspect(video_aspect)
|
||||
video_orientation = aspect.name
|
||||
video_width, video_height = aspect.to_resolution()
|
||||
api_key = get_api_key("pexels_api_keys")
|
||||
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
|
||||
params = {"query": search_term, "per_page": 20, "orientation": video_orientation}
|
||||
query_url = f"https://api.pexels.com/videos/search?{urlencode(params)}"
|
||||
logger.info(f"searching videos: {query_url}, with proxies: {config.proxy}")
|
||||
|
||||
try:
|
||||
r = requests.get(
|
||||
query_url,
|
||||
headers=headers,
|
||||
proxies=config.proxy,
|
||||
verify=_get_tls_verify(),
|
||||
timeout=(30, 60),
|
||||
)
|
||||
response = r.json()
|
||||
video_items = []
|
||||
if "videos" not in response:
|
||||
logger.error(f"search videos failed: {response}")
|
||||
return video_items
|
||||
videos = response["videos"]
|
||||
# loop through each video in the result
|
||||
for v in videos:
|
||||
duration = v["duration"]
|
||||
# check if video has desired minimum duration
|
||||
if duration < minimum_duration:
|
||||
continue
|
||||
video_files = v["video_files"]
|
||||
# loop through each url to determine the best quality
|
||||
for video in video_files:
|
||||
w = int(video["width"])
|
||||
h = int(video["height"])
|
||||
if w == video_width and h == video_height:
|
||||
item = MaterialInfo()
|
||||
item.provider = "pexels"
|
||||
item.url = video["link"]
|
||||
item.duration = duration
|
||||
video_items.append(item)
|
||||
break
|
||||
return video_items
|
||||
except Exception as e:
|
||||
logger.error(f"search videos failed: {str(e)}")
|
||||
|
||||
return []
|
||||
|
||||
|
||||
def search_videos_pixabay(
|
||||
search_term: str,
|
||||
minimum_duration: int,
|
||||
video_aspect: VideoAspect = VideoAspect.portrait,
|
||||
) -> List[MaterialInfo]:
|
||||
aspect = VideoAspect(video_aspect)
|
||||
|
||||
video_width, video_height = aspect.to_resolution()
|
||||
|
||||
api_key = get_api_key("pixabay_api_keys")
|
||||
# Build URL
|
||||
params = {
|
||||
"q": search_term,
|
||||
"video_type": "all", # Accepted values: "all", "film", "animation"
|
||||
"per_page": 50,
|
||||
"key": api_key,
|
||||
}
|
||||
query_url = f"https://pixabay.com/api/videos/?{urlencode(params)}"
|
||||
logger.info(f"searching videos: {query_url}, with proxies: {config.proxy}")
|
||||
|
||||
try:
|
||||
r = requests.get(
|
||||
query_url, proxies=config.proxy, verify=_get_tls_verify(), timeout=(30, 60)
|
||||
)
|
||||
response = r.json()
|
||||
video_items = []
|
||||
if "hits" not in response:
|
||||
logger.error(f"search videos failed: {response}")
|
||||
return video_items
|
||||
videos = response["hits"]
|
||||
# loop through each video in the result
|
||||
for v in videos:
|
||||
duration = v["duration"]
|
||||
# check if video has desired minimum duration
|
||||
if duration < minimum_duration:
|
||||
continue
|
||||
video_files = v["videos"]
|
||||
# loop through each url to determine the best quality
|
||||
for video_type in video_files:
|
||||
video = video_files[video_type]
|
||||
w = int(video["width"])
|
||||
# h = int(video["height"])
|
||||
if w >= video_width:
|
||||
item = MaterialInfo()
|
||||
item.provider = "pixabay"
|
||||
item.url = video["url"]
|
||||
item.duration = duration
|
||||
video_items.append(item)
|
||||
break
|
||||
return video_items
|
||||
except Exception as e:
|
||||
logger.error(f"search videos failed: {str(e)}")
|
||||
|
||||
return []
|
||||
|
||||
|
||||
def save_video(video_url: str, save_dir: str = "") -> str:
|
||||
if not save_dir:
|
||||
save_dir = utils.storage_dir("cache_videos")
|
||||
|
||||
if not os.path.exists(save_dir):
|
||||
os.makedirs(save_dir)
|
||||
|
||||
url_without_query = video_url.split("?")[0]
|
||||
url_hash = utils.md5(url_without_query)
|
||||
video_id = f"vid-{url_hash}"
|
||||
video_path = f"{save_dir}/{video_id}.mp4"
|
||||
|
||||
# if video already exists, return the path
|
||||
if os.path.exists(video_path) and os.path.getsize(video_path) > 0:
|
||||
logger.info(f"video already exists: {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
|
||||
with open(video_path, "wb") as f:
|
||||
f.write(
|
||||
requests.get(
|
||||
video_url,
|
||||
headers=headers,
|
||||
proxies=config.proxy,
|
||||
verify=_get_tls_verify(),
|
||||
timeout=(60, 240),
|
||||
).content
|
||||
)
|
||||
|
||||
if os.path.exists(video_path) and os.path.getsize(video_path) > 0:
|
||||
clip = None
|
||||
try:
|
||||
clip = VideoFileClip(video_path)
|
||||
duration = clip.duration
|
||||
fps = clip.fps
|
||||
if duration > 0 and fps > 0:
|
||||
return video_path
|
||||
except Exception as e:
|
||||
logger.warning(f"invalid video file: {video_path} => {str(e)}")
|
||||
try:
|
||||
os.remove(video_path)
|
||||
except Exception as remove_error:
|
||||
logger.warning(
|
||||
f"failed to remove invalid video file: {video_path}, error: {str(remove_error)}"
|
||||
)
|
||||
finally:
|
||||
if clip is not None:
|
||||
try:
|
||||
clip.close()
|
||||
except Exception as close_error:
|
||||
logger.warning(
|
||||
f"failed to close video clip: {video_path}, error: {str(close_error)}"
|
||||
)
|
||||
return ""
|
||||
|
||||
|
||||
def download_videos(
|
||||
task_id: str,
|
||||
search_terms: List[str],
|
||||
source: str = "pexels",
|
||||
video_aspect: VideoAspect = VideoAspect.portrait,
|
||||
video_contact_mode: VideoConcatMode = VideoConcatMode.random,
|
||||
audio_duration: float = 0.0,
|
||||
max_clip_duration: int = 5,
|
||||
) -> List[str]:
|
||||
valid_video_items = []
|
||||
valid_video_urls = []
|
||||
found_duration = 0.0
|
||||
search_videos = search_videos_pexels
|
||||
if source == "pixabay":
|
||||
search_videos = search_videos_pixabay
|
||||
|
||||
for search_term in search_terms:
|
||||
video_items = search_videos(
|
||||
search_term=search_term,
|
||||
minimum_duration=max_clip_duration,
|
||||
video_aspect=video_aspect,
|
||||
)
|
||||
logger.info(f"found {len(video_items)} videos for '{search_term}'")
|
||||
|
||||
for item in video_items:
|
||||
if item.url not in valid_video_urls:
|
||||
valid_video_items.append(item)
|
||||
valid_video_urls.append(item.url)
|
||||
found_duration += item.duration
|
||||
|
||||
logger.info(
|
||||
f"found total videos: {len(valid_video_items)}, required duration: {audio_duration} seconds, found duration: {found_duration} seconds"
|
||||
)
|
||||
video_paths = []
|
||||
|
||||
material_directory = config.app.get("material_directory", "").strip()
|
||||
if material_directory == "task":
|
||||
material_directory = utils.task_dir(task_id)
|
||||
elif material_directory and not os.path.isdir(material_directory):
|
||||
material_directory = ""
|
||||
|
||||
concat_mode_value = getattr(video_contact_mode, "value", video_contact_mode)
|
||||
if concat_mode_value == VideoConcatMode.random.value:
|
||||
random.shuffle(valid_video_items)
|
||||
|
||||
total_duration = 0.0
|
||||
for item in valid_video_items:
|
||||
try:
|
||||
logger.info(f"downloading video: {item.url}")
|
||||
saved_video_path = save_video(
|
||||
video_url=item.url, save_dir=material_directory
|
||||
)
|
||||
if saved_video_path:
|
||||
logger.info(f"video saved: {saved_video_path}")
|
||||
video_paths.append(saved_video_path)
|
||||
seconds = min(max_clip_duration, item.duration)
|
||||
total_duration += seconds
|
||||
if total_duration > audio_duration:
|
||||
logger.info(
|
||||
f"total duration of downloaded videos: {total_duration} seconds, skip downloading more"
|
||||
)
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"failed to download video: {utils.to_json(item)} => {str(e)}")
|
||||
logger.success(f"downloaded {len(video_paths)} videos")
|
||||
return video_paths
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
download_videos(
|
||||
"test123", ["Money Exchange Medium"], audio_duration=100, source="pixabay"
|
||||
)
|
||||
168
app/services/state.py
Normal file
168
app/services/state.py
Normal file
@@ -0,0 +1,168 @@
|
||||
import ast
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
from app.config import config
|
||||
from app.models import const
|
||||
|
||||
|
||||
# Base class for state management
|
||||
class BaseState(ABC):
|
||||
@abstractmethod
|
||||
def update_task(self, task_id: str, state: int, progress: int = 0, **kwargs):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_task(self, task_id: str):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_all_tasks(self, page: int, page_size: int):
|
||||
pass
|
||||
|
||||
|
||||
# Memory state management
|
||||
class MemoryState(BaseState):
|
||||
def __init__(self):
|
||||
self._tasks = {}
|
||||
|
||||
def get_all_tasks(self, page: int, page_size: int):
|
||||
start = (page - 1) * page_size
|
||||
end = start + page_size
|
||||
tasks = list(self._tasks.values())
|
||||
total = len(tasks)
|
||||
return tasks[start:end], total
|
||||
|
||||
def update_task(
|
||||
self,
|
||||
task_id: str,
|
||||
state: int = const.TASK_STATE_PROCESSING,
|
||||
progress: int = 0,
|
||||
**kwargs,
|
||||
):
|
||||
progress = int(progress)
|
||||
if progress > 100:
|
||||
progress = 100
|
||||
|
||||
self._tasks[task_id] = {
|
||||
"task_id": task_id,
|
||||
"state": state,
|
||||
"progress": progress,
|
||||
**kwargs,
|
||||
}
|
||||
|
||||
def get_task(self, task_id: str):
|
||||
return self._tasks.get(task_id, None)
|
||||
|
||||
def delete_task(self, task_id: str):
|
||||
if task_id in self._tasks:
|
||||
del self._tasks[task_id]
|
||||
|
||||
|
||||
# Redis state management
|
||||
class RedisState(BaseState):
|
||||
def __init__(self, host="localhost", port=6379, db=0, password=None):
|
||||
import redis
|
||||
|
||||
self._redis = redis.StrictRedis(host=host, port=port, db=db, password=password)
|
||||
|
||||
def get_all_tasks(self, page: int, page_size: int):
|
||||
start = (page - 1) * page_size
|
||||
end = start + page_size
|
||||
tasks = []
|
||||
cursor = 0
|
||||
total = 0
|
||||
while True:
|
||||
cursor, keys = self._redis.scan(cursor, count=page_size)
|
||||
batch_start = total
|
||||
batch_size = len(keys)
|
||||
total += batch_size
|
||||
|
||||
# Redis SCAN 是分批返回 key。分页切片必须基于“当前批次起始索引”
|
||||
# 计算,而不能用累积后的 total 反推,否则第一页会切到空数组,
|
||||
# 第二页也可能只返回部分数据。
|
||||
if batch_start < end and total > start:
|
||||
slice_start = max(0, start - batch_start)
|
||||
slice_end = min(batch_size, end - batch_start)
|
||||
for key in keys[slice_start:slice_end]:
|
||||
task_data = self._redis.hgetall(key)
|
||||
task = {
|
||||
k.decode("utf-8"): self._convert_to_original_type(v)
|
||||
for k, v in task_data.items()
|
||||
}
|
||||
tasks.append(task)
|
||||
|
||||
# 即使当前页已经取满,也要继续 SCAN 到 cursor=0,
|
||||
# 因为调用方需要准确 total 来渲染分页信息。
|
||||
if cursor == 0:
|
||||
break
|
||||
return tasks, total
|
||||
|
||||
def update_task(
|
||||
self,
|
||||
task_id: str,
|
||||
state: int = const.TASK_STATE_PROCESSING,
|
||||
progress: int = 0,
|
||||
**kwargs,
|
||||
):
|
||||
progress = int(progress)
|
||||
if progress > 100:
|
||||
progress = 100
|
||||
|
||||
fields = {
|
||||
"task_id": task_id,
|
||||
"state": state,
|
||||
"progress": progress,
|
||||
**kwargs,
|
||||
}
|
||||
|
||||
for field, value in fields.items():
|
||||
self._redis.hset(task_id, field, str(value))
|
||||
|
||||
def get_task(self, task_id: str):
|
||||
task_data = self._redis.hgetall(task_id)
|
||||
if not task_data:
|
||||
return None
|
||||
|
||||
task = {
|
||||
key.decode("utf-8"): self._convert_to_original_type(value)
|
||||
for key, value in task_data.items()
|
||||
}
|
||||
return task
|
||||
|
||||
def delete_task(self, task_id: str):
|
||||
self._redis.delete(task_id)
|
||||
|
||||
@staticmethod
|
||||
def _convert_to_original_type(value):
|
||||
"""
|
||||
Convert the value from byte string to its original data type.
|
||||
You can extend this method to handle other data types as needed.
|
||||
"""
|
||||
value_str = value.decode("utf-8")
|
||||
|
||||
try:
|
||||
# try to convert byte string array to list
|
||||
return ast.literal_eval(value_str)
|
||||
except (ValueError, SyntaxError):
|
||||
pass
|
||||
|
||||
if value_str.isdigit():
|
||||
return int(value_str)
|
||||
# Add more conversions here if needed
|
||||
return value_str
|
||||
|
||||
|
||||
# Global state
|
||||
_enable_redis = config.app.get("enable_redis", False)
|
||||
_redis_host = config.app.get("redis_host", "localhost")
|
||||
_redis_port = config.app.get("redis_port", 6379)
|
||||
_redis_db = config.app.get("redis_db", 0)
|
||||
_redis_password = config.app.get("redis_password", None)
|
||||
|
||||
state = (
|
||||
RedisState(
|
||||
host=_redis_host, port=_redis_port, db=_redis_db, password=_redis_password
|
||||
)
|
||||
if _enable_redis
|
||||
else MemoryState()
|
||||
)
|
||||
305
app/services/subtitle.py
Normal file
305
app/services/subtitle.py
Normal file
@@ -0,0 +1,305 @@
|
||||
import json
|
||||
import os.path
|
||||
import re
|
||||
from timeit import default_timer as timer
|
||||
|
||||
try:
|
||||
from faster_whisper import WhisperModel
|
||||
except ImportError:
|
||||
WhisperModel = None
|
||||
from loguru import logger
|
||||
|
||||
from app.config import config
|
||||
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 = None
|
||||
|
||||
|
||||
def create(audio_file, subtitle_file: str = ""):
|
||||
global model
|
||||
if WhisperModel is None:
|
||||
logger.warning("faster_whisper not available, skipping whisper subtitle generation")
|
||||
return ""
|
||||
if not model:
|
||||
model_path = f"{utils.root_dir()}/models/whisper-{model_size}"
|
||||
model_bin_file = f"{model_path}/model.bin"
|
||||
if not os.path.isdir(model_path) or not os.path.isfile(model_bin_file):
|
||||
model_path = model_size
|
||||
|
||||
logger.info(
|
||||
f"loading model: {model_path}, device: {device}, compute_type: {compute_type}"
|
||||
)
|
||||
try:
|
||||
model = WhisperModel(
|
||||
model_size_or_path=model_path, device=device, compute_type=compute_type
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"failed to load model: {e} \n\n"
|
||||
f"********************************************\n"
|
||||
f"this may be caused by network issue. \n"
|
||||
f"please download the model manually and put it in the 'models' folder. \n"
|
||||
f"see [README.md FAQ](https://github.com/harry0703/MoneyPrinterTurbo) for more details.\n"
|
||||
f"********************************************\n\n"
|
||||
)
|
||||
return None
|
||||
|
||||
logger.info(f"start, output file: {subtitle_file}")
|
||||
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
|
||||
# If it contains punctuation, then break the sentence.
|
||||
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) + "\n"
|
||||
with open(subtitle_file, "w", encoding="utf-8") as f:
|
||||
f.write(sub)
|
||||
logger.info(f"subtitle file created: {subtitle_file}")
|
||||
|
||||
|
||||
def file_to_subtitles(filename):
|
||||
if not filename or not os.path.isfile(filename):
|
||||
return []
|
||||
|
||||
times_texts = []
|
||||
current_times = None
|
||||
current_text = ""
|
||||
index = 0
|
||||
with open(filename, "r", encoding="utf-8") 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 levenshtein_distance(s1, s2):
|
||||
if len(s1) < len(s2):
|
||||
return levenshtein_distance(s2, s1)
|
||||
|
||||
if len(s2) == 0:
|
||||
return len(s1)
|
||||
|
||||
previous_row = range(len(s2) + 1)
|
||||
for i, c1 in enumerate(s1):
|
||||
current_row = [i + 1]
|
||||
for j, c2 in enumerate(s2):
|
||||
insertions = previous_row[j + 1] + 1
|
||||
deletions = current_row[j] + 1
|
||||
substitutions = previous_row[j] + (c1 != c2)
|
||||
current_row.append(min(insertions, deletions, substitutions))
|
||||
previous_row = current_row
|
||||
|
||||
return previous_row[-1]
|
||||
|
||||
|
||||
def similarity(a, b):
|
||||
distance = levenshtein_distance(a.lower(), b.lower())
|
||||
max_length = max(len(a), len(b))
|
||||
return 1 - (distance / max_length)
|
||||
|
||||
|
||||
def correct(subtitle_file, video_script):
|
||||
subtitle_items = file_to_subtitles(subtitle_file)
|
||||
script_lines = utils.split_string_by_punctuations(video_script)
|
||||
|
||||
corrected = False
|
||||
new_subtitle_items = []
|
||||
script_index = 0
|
||||
subtitle_index = 0
|
||||
|
||||
while script_index < len(script_lines) and subtitle_index < len(subtitle_items):
|
||||
script_line = script_lines[script_index].strip()
|
||||
subtitle_line = subtitle_items[subtitle_index][2].strip()
|
||||
|
||||
if script_line == subtitle_line:
|
||||
new_subtitle_items.append(subtitle_items[subtitle_index])
|
||||
script_index += 1
|
||||
subtitle_index += 1
|
||||
else:
|
||||
combined_subtitle = subtitle_line
|
||||
start_time = subtitle_items[subtitle_index][1].split(" --> ")[0]
|
||||
end_time = subtitle_items[subtitle_index][1].split(" --> ")[1]
|
||||
next_subtitle_index = subtitle_index + 1
|
||||
|
||||
while next_subtitle_index < len(subtitle_items):
|
||||
next_subtitle = subtitle_items[next_subtitle_index][2].strip()
|
||||
if similarity(
|
||||
script_line, combined_subtitle + " " + next_subtitle
|
||||
) > similarity(script_line, combined_subtitle):
|
||||
combined_subtitle += " " + next_subtitle
|
||||
end_time = subtitle_items[next_subtitle_index][1].split(" --> ")[1]
|
||||
next_subtitle_index += 1
|
||||
else:
|
||||
break
|
||||
|
||||
if similarity(script_line, combined_subtitle) > 0.8:
|
||||
logger.warning(
|
||||
f"Merged/Corrected - Script: {script_line}, Subtitle: {combined_subtitle}"
|
||||
)
|
||||
new_subtitle_items.append(
|
||||
(
|
||||
len(new_subtitle_items) + 1,
|
||||
f"{start_time} --> {end_time}",
|
||||
script_line,
|
||||
)
|
||||
)
|
||||
corrected = True
|
||||
else:
|
||||
logger.warning(
|
||||
f"Mismatch - Script: {script_line}, Subtitle: {combined_subtitle}"
|
||||
)
|
||||
new_subtitle_items.append(
|
||||
(
|
||||
len(new_subtitle_items) + 1,
|
||||
f"{start_time} --> {end_time}",
|
||||
script_line,
|
||||
)
|
||||
)
|
||||
corrected = True
|
||||
|
||||
script_index += 1
|
||||
subtitle_index = next_subtitle_index
|
||||
|
||||
# Process the remaining lines of the script.
|
||||
while script_index < len(script_lines):
|
||||
logger.warning(f"Extra script line: {script_lines[script_index]}")
|
||||
if subtitle_index < len(subtitle_items):
|
||||
new_subtitle_items.append(
|
||||
(
|
||||
len(new_subtitle_items) + 1,
|
||||
subtitle_items[subtitle_index][1],
|
||||
script_lines[script_index],
|
||||
)
|
||||
)
|
||||
subtitle_index += 1
|
||||
else:
|
||||
new_subtitle_items.append(
|
||||
(
|
||||
len(new_subtitle_items) + 1,
|
||||
"00:00:00,000 --> 00:00:00,000",
|
||||
script_lines[script_index],
|
||||
)
|
||||
)
|
||||
script_index += 1
|
||||
corrected = True
|
||||
|
||||
if corrected:
|
||||
with open(subtitle_file, "w", encoding="utf-8") as fd:
|
||||
for i, item in enumerate(new_subtitle_items):
|
||||
fd.write(f"{i + 1}\n{item[1]}\n{item[2]}\n\n")
|
||||
logger.info("Subtitle corrected")
|
||||
else:
|
||||
logger.success("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"
|
||||
audio_file = f"{task_dir}/audio.mp3"
|
||||
|
||||
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)
|
||||
|
||||
subtitle_file = f"{task_dir}/subtitle-test.srt"
|
||||
create(audio_file, subtitle_file)
|
||||
400
app/services/task.py
Normal file
400
app/services/task.py
Normal file
@@ -0,0 +1,400 @@
|
||||
import math
|
||||
import os.path
|
||||
import re
|
||||
from os import path
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from app.config import config
|
||||
from app.models import const
|
||||
from app.models.schema import VideoConcatMode, VideoParams
|
||||
from app.services import llm, material, subtitle, video, voice, upload_post
|
||||
from app.services import state as sm
|
||||
from app.utils import utils
|
||||
|
||||
|
||||
def generate_script(task_id, params):
|
||||
logger.info("\n\n## generating video script")
|
||||
video_script = params.video_script.strip()
|
||||
if not video_script:
|
||||
video_script = llm.generate_script(
|
||||
video_subject=params.video_subject,
|
||||
language=params.video_language,
|
||||
paragraph_number=params.paragraph_number,
|
||||
video_script_prompt=params.video_script_prompt,
|
||||
custom_system_prompt=params.custom_system_prompt,
|
||||
)
|
||||
else:
|
||||
logger.debug(f"video script: \n{video_script}")
|
||||
|
||||
if not video_script:
|
||||
sm.state.update_task(task_id, state=const.TASK_STATE_FAILED)
|
||||
logger.error("failed to generate video script.")
|
||||
return None
|
||||
|
||||
return video_script
|
||||
|
||||
|
||||
def generate_terms(task_id, params, video_script):
|
||||
logger.info("\n\n## generating video terms")
|
||||
video_terms = params.video_terms
|
||||
if not video_terms:
|
||||
video_terms = llm.generate_terms(
|
||||
video_subject=params.video_subject, video_script=video_script, amount=5
|
||||
)
|
||||
else:
|
||||
if isinstance(video_terms, str):
|
||||
video_terms = [term.strip() for term in re.split(r"[,,]", video_terms)]
|
||||
elif isinstance(video_terms, list):
|
||||
video_terms = [term.strip() for term in video_terms]
|
||||
else:
|
||||
raise ValueError("video_terms must be a string or a list of strings.")
|
||||
|
||||
logger.debug(f"video terms: {utils.to_json(video_terms)}")
|
||||
|
||||
if not video_terms:
|
||||
sm.state.update_task(task_id, state=const.TASK_STATE_FAILED)
|
||||
logger.error("failed to generate video terms.")
|
||||
return None
|
||||
|
||||
return video_terms
|
||||
|
||||
|
||||
def save_script_data(task_id, video_script, video_terms, params):
|
||||
script_file = path.join(utils.task_dir(task_id), "script.json")
|
||||
script_data = {
|
||||
"script": video_script,
|
||||
"search_terms": video_terms,
|
||||
"params": params,
|
||||
}
|
||||
|
||||
with open(script_file, "w", encoding="utf-8") as f:
|
||||
f.write(utils.to_json(script_data))
|
||||
|
||||
|
||||
def generate_audio(task_id, params, video_script):
|
||||
'''
|
||||
Generate audio for the video script.
|
||||
If a custom audio file is provided, it will be used directly.
|
||||
There will be no subtitle maker object returned in this case.
|
||||
Otherwise, TTS will be used to generate the audio.
|
||||
Returns:
|
||||
- audio_file: path to the generated or provided audio file
|
||||
- audio_duration: duration of the audio in seconds
|
||||
- sub_maker: subtitle maker object if TTS is used, None otherwise
|
||||
'''
|
||||
logger.info("\n\n## generating audio")
|
||||
# /audio 和 /subtitle 请求模型不包含 custom_audio_file,
|
||||
# 这里统一做兼容读取,避免直调接口时抛属性错误。
|
||||
custom_audio_file = getattr(params, "custom_audio_file", None)
|
||||
if not custom_audio_file or not os.path.exists(custom_audio_file):
|
||||
if custom_audio_file:
|
||||
logger.warning(
|
||||
f"custom audio file not found: {custom_audio_file}, using TTS to generate audio."
|
||||
)
|
||||
else:
|
||||
logger.info("no custom audio file provided, using TTS to generate audio.")
|
||||
audio_file = path.join(utils.task_dir(task_id), "audio.mp3")
|
||||
sub_maker = voice.tts(
|
||||
text=video_script,
|
||||
voice_name=voice.parse_voice_name(params.voice_name),
|
||||
voice_rate=params.voice_rate,
|
||||
voice_file=audio_file,
|
||||
)
|
||||
if sub_maker is None:
|
||||
sm.state.update_task(task_id, state=const.TASK_STATE_FAILED)
|
||||
logger.error(
|
||||
"""failed to generate audio:
|
||||
1. check if the language of the voice matches the language of the video script.
|
||||
2. check if the network is available. If you are in China, it is recommended to use a VPN and enable the global traffic mode.
|
||||
""".strip()
|
||||
)
|
||||
return None, None, None
|
||||
audio_duration = math.ceil(voice.get_audio_duration(sub_maker))
|
||||
if audio_duration == 0:
|
||||
sm.state.update_task(task_id, state=const.TASK_STATE_FAILED)
|
||||
logger.error("failed to get audio duration.")
|
||||
return None, None, None
|
||||
return audio_file, audio_duration, sub_maker
|
||||
else:
|
||||
logger.info(f"using custom audio file: {custom_audio_file}")
|
||||
audio_duration = voice.get_audio_duration(custom_audio_file)
|
||||
if audio_duration == 0:
|
||||
sm.state.update_task(task_id, state=const.TASK_STATE_FAILED)
|
||||
logger.error("failed to get audio duration from custom audio file.")
|
||||
return None, None, None
|
||||
return custom_audio_file, audio_duration, None
|
||||
|
||||
def generate_subtitle(task_id, params, video_script, sub_maker, audio_file):
|
||||
'''
|
||||
Generate subtitle for the video script.
|
||||
If subtitle generation is disabled or no subtitle maker is provided, it will return an empty string.
|
||||
Otherwise, it will generate the subtitle using the specified provider.
|
||||
Returns:
|
||||
- subtitle_path: path to the generated subtitle file
|
||||
'''
|
||||
logger.info("\n\n## generating subtitle")
|
||||
if not params.subtitle_enabled or sub_maker is None:
|
||||
return ""
|
||||
|
||||
subtitle_path = path.join(utils.task_dir(task_id), "subtitle.srt")
|
||||
subtitle_provider = config.app.get("subtitle_provider", "edge").strip().lower()
|
||||
logger.info(f"\n\n## generating subtitle, provider: {subtitle_provider}")
|
||||
|
||||
subtitle_fallback = False
|
||||
if subtitle_provider == "edge":
|
||||
voice.create_subtitle(
|
||||
text=video_script, sub_maker=sub_maker, subtitle_file=subtitle_path
|
||||
)
|
||||
if not os.path.exists(subtitle_path):
|
||||
subtitle_fallback = True
|
||||
logger.warning("subtitle file not found, fallback to whisper")
|
||||
|
||||
if subtitle_provider == "whisper" or subtitle_fallback:
|
||||
subtitle.create(audio_file=audio_file, subtitle_file=subtitle_path)
|
||||
logger.info("\n\n## correcting subtitle")
|
||||
subtitle.correct(subtitle_file=subtitle_path, video_script=video_script)
|
||||
|
||||
subtitle_lines = subtitle.file_to_subtitles(subtitle_path)
|
||||
if not subtitle_lines:
|
||||
logger.warning(f"subtitle file is invalid: {subtitle_path}")
|
||||
return ""
|
||||
|
||||
return subtitle_path
|
||||
|
||||
|
||||
def get_video_materials(task_id, params, video_terms, audio_duration):
|
||||
if params.video_source == "local":
|
||||
logger.info("\n\n## preprocess local materials")
|
||||
materials = video.preprocess_video(
|
||||
materials=params.video_materials, clip_duration=params.video_clip_duration
|
||||
)
|
||||
if not materials:
|
||||
sm.state.update_task(task_id, state=const.TASK_STATE_FAILED)
|
||||
logger.error(
|
||||
"no valid materials found, please check the materials and try again."
|
||||
)
|
||||
return None
|
||||
|
||||
return [material_info.url for material_info in materials]
|
||||
else:
|
||||
logger.info(f"\n\n## downloading videos from {params.video_source}")
|
||||
downloaded_videos = material.download_videos(
|
||||
task_id=task_id,
|
||||
search_terms=video_terms,
|
||||
source=params.video_source,
|
||||
video_aspect=params.video_aspect,
|
||||
video_contact_mode=params.video_concat_mode,
|
||||
audio_duration=audio_duration * params.video_count,
|
||||
max_clip_duration=params.video_clip_duration,
|
||||
)
|
||||
if not downloaded_videos:
|
||||
sm.state.update_task(task_id, state=const.TASK_STATE_FAILED)
|
||||
logger.error(
|
||||
"failed to download videos, maybe the network is not available. if you are in China, please use a VPN."
|
||||
)
|
||||
return None
|
||||
return downloaded_videos
|
||||
|
||||
|
||||
def generate_final_videos(
|
||||
task_id, params, downloaded_videos, audio_file, subtitle_path
|
||||
):
|
||||
final_video_paths = []
|
||||
combined_video_paths = []
|
||||
video_concat_mode = (
|
||||
params.video_concat_mode if params.video_count == 1 else VideoConcatMode.random
|
||||
)
|
||||
video_transition_mode = params.video_transition_mode
|
||||
|
||||
_progress = 50
|
||||
for i in range(params.video_count):
|
||||
index = i + 1
|
||||
combined_video_path = path.join(
|
||||
utils.task_dir(task_id), f"combined-{index}.mp4"
|
||||
)
|
||||
logger.info(f"\n\n## combining video: {index} => {combined_video_path}")
|
||||
video.combine_videos(
|
||||
combined_video_path=combined_video_path,
|
||||
video_paths=downloaded_videos,
|
||||
audio_file=audio_file,
|
||||
video_aspect=params.video_aspect,
|
||||
video_concat_mode=video_concat_mode,
|
||||
video_transition_mode=video_transition_mode,
|
||||
max_clip_duration=params.video_clip_duration,
|
||||
threads=params.n_threads,
|
||||
)
|
||||
|
||||
_progress += 50 / params.video_count / 2
|
||||
sm.state.update_task(task_id, progress=_progress)
|
||||
|
||||
final_video_path = path.join(utils.task_dir(task_id), f"final-{index}.mp4")
|
||||
|
||||
logger.info(f"\n\n## generating video: {index} => {final_video_path}")
|
||||
video.generate_video(
|
||||
video_path=combined_video_path,
|
||||
audio_path=audio_file,
|
||||
subtitle_path=subtitle_path,
|
||||
output_file=final_video_path,
|
||||
params=params,
|
||||
)
|
||||
|
||||
_progress += 50 / params.video_count / 2
|
||||
sm.state.update_task(task_id, progress=_progress)
|
||||
|
||||
final_video_paths.append(final_video_path)
|
||||
combined_video_paths.append(combined_video_path)
|
||||
|
||||
return final_video_paths, combined_video_paths
|
||||
|
||||
|
||||
def start(task_id, params: VideoParams, stop_at: str = "video"):
|
||||
logger.info(f"params:{params}")
|
||||
logger.info(f"start task: {task_id}, stop_at: {stop_at}")
|
||||
sm.state.update_task(task_id, state=const.TASK_STATE_PROCESSING, progress=5)
|
||||
|
||||
# 1. Generate script
|
||||
video_script = generate_script(task_id, params)
|
||||
if not video_script or "Error: " in video_script:
|
||||
sm.state.update_task(task_id, state=const.TASK_STATE_FAILED)
|
||||
return
|
||||
|
||||
sm.state.update_task(task_id, state=const.TASK_STATE_PROCESSING, progress=10)
|
||||
|
||||
if stop_at == "script":
|
||||
sm.state.update_task(
|
||||
task_id, state=const.TASK_STATE_COMPLETE, progress=100, script=video_script
|
||||
)
|
||||
return {"script": video_script}
|
||||
|
||||
# 2. Generate terms
|
||||
video_terms = ""
|
||||
if params.video_source != "local":
|
||||
video_terms = generate_terms(task_id, params, video_script)
|
||||
if not video_terms:
|
||||
sm.state.update_task(task_id, state=const.TASK_STATE_FAILED)
|
||||
return
|
||||
|
||||
save_script_data(task_id, video_script, video_terms, params)
|
||||
|
||||
if stop_at == "terms":
|
||||
sm.state.update_task(
|
||||
task_id, state=const.TASK_STATE_COMPLETE, progress=100, terms=video_terms
|
||||
)
|
||||
return {"script": video_script, "terms": video_terms}
|
||||
|
||||
sm.state.update_task(task_id, state=const.TASK_STATE_PROCESSING, progress=20)
|
||||
|
||||
# 3. Generate audio
|
||||
audio_file, audio_duration, sub_maker = generate_audio(
|
||||
task_id, params, video_script
|
||||
)
|
||||
if not audio_file:
|
||||
sm.state.update_task(task_id, state=const.TASK_STATE_FAILED)
|
||||
return
|
||||
|
||||
sm.state.update_task(task_id, state=const.TASK_STATE_PROCESSING, progress=30)
|
||||
|
||||
if stop_at == "audio":
|
||||
sm.state.update_task(
|
||||
task_id,
|
||||
state=const.TASK_STATE_COMPLETE,
|
||||
progress=100,
|
||||
audio_file=audio_file,
|
||||
)
|
||||
return {"audio_file": audio_file, "audio_duration": audio_duration}
|
||||
|
||||
# 4. Generate subtitle
|
||||
subtitle_path = generate_subtitle(
|
||||
task_id, params, video_script, sub_maker, audio_file
|
||||
)
|
||||
|
||||
if stop_at == "subtitle":
|
||||
sm.state.update_task(
|
||||
task_id,
|
||||
state=const.TASK_STATE_COMPLETE,
|
||||
progress=100,
|
||||
subtitle_path=subtitle_path,
|
||||
)
|
||||
return {"subtitle_path": subtitle_path}
|
||||
|
||||
sm.state.update_task(task_id, state=const.TASK_STATE_PROCESSING, progress=40)
|
||||
|
||||
# 5. Get video materials
|
||||
downloaded_videos = get_video_materials(
|
||||
task_id, params, video_terms, audio_duration
|
||||
)
|
||||
if not downloaded_videos:
|
||||
sm.state.update_task(task_id, state=const.TASK_STATE_FAILED)
|
||||
return
|
||||
|
||||
if stop_at == "materials":
|
||||
sm.state.update_task(
|
||||
task_id,
|
||||
state=const.TASK_STATE_COMPLETE,
|
||||
progress=100,
|
||||
materials=downloaded_videos,
|
||||
)
|
||||
return {"materials": downloaded_videos}
|
||||
|
||||
sm.state.update_task(task_id, state=const.TASK_STATE_PROCESSING, progress=50)
|
||||
|
||||
# 仅完整视频生成流程才需要处理视频拼接模式;
|
||||
# 这样可以避免 /subtitle 和 /audio 这类请求访问不存在的字段。
|
||||
if type(params.video_concat_mode) is str:
|
||||
params.video_concat_mode = VideoConcatMode(params.video_concat_mode)
|
||||
|
||||
# 6. Generate final videos
|
||||
logger.info(f"\n\n## cross-posting videos to TikTok/Instagram{task_id}, {params}, {downloaded_videos}, {audio_file}, {subtitle_path}")
|
||||
final_video_paths, combined_video_paths = generate_final_videos(
|
||||
task_id, params, downloaded_videos, audio_file, subtitle_path
|
||||
)
|
||||
|
||||
if not final_video_paths:
|
||||
sm.state.update_task(task_id, state=const.TASK_STATE_FAILED)
|
||||
return
|
||||
|
||||
logger.success(
|
||||
f"task {task_id} finished, generated {len(final_video_paths)} videos."
|
||||
)
|
||||
|
||||
# 7. Cross-post to TikTok/Instagram (if enabled)
|
||||
cross_post_results = []
|
||||
if upload_post.upload_post_service.is_configured() and upload_post.upload_post_service.auto_upload:
|
||||
logger.info("\n\n## cross-posting videos to TikTok/Instagram")
|
||||
for video_path in final_video_paths:
|
||||
result = upload_post.cross_post_video(
|
||||
video_path=video_path,
|
||||
title=params.video_subject or "Check out this video! #shorts #viral"
|
||||
)
|
||||
cross_post_results.append(result)
|
||||
if result.get('success'):
|
||||
logger.info(f"✅ Cross-posted: {video_path}")
|
||||
else:
|
||||
logger.warning(f"⚠️ Failed to cross-post: {video_path} - {result.get('error', 'Unknown error')}")
|
||||
|
||||
kwargs = {
|
||||
"videos": final_video_paths,
|
||||
"combined_videos": combined_video_paths,
|
||||
"script": video_script,
|
||||
"terms": video_terms,
|
||||
"audio_file": audio_file,
|
||||
"audio_duration": audio_duration,
|
||||
"subtitle_path": subtitle_path,
|
||||
"materials": downloaded_videos,
|
||||
"cross_post_results": cross_post_results if cross_post_results else None,
|
||||
}
|
||||
sm.state.update_task(
|
||||
task_id, state=const.TASK_STATE_COMPLETE, progress=100, **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")
|
||||
147
app/services/upload_post.py
Normal file
147
app/services/upload_post.py
Normal file
@@ -0,0 +1,147 @@
|
||||
"""
|
||||
Upload-Post API integration for cross-posting videos to TikTok and Instagram.
|
||||
|
||||
Docs: https://docs.upload-post.com
|
||||
"""
|
||||
import os
|
||||
import requests
|
||||
from loguru import logger
|
||||
from app.config import config
|
||||
|
||||
|
||||
class UploadPostService:
|
||||
"""
|
||||
Service for cross-posting videos to TikTok/Instagram via Upload-Post API.
|
||||
"""
|
||||
|
||||
API_BASE = "https://api.upload-post.com"
|
||||
|
||||
def __init__(self):
|
||||
self.api_key = config.app.get("upload_post_api_key", "")
|
||||
self.username = config.app.get("upload_post_username", "")
|
||||
self.enabled = config.app.get("upload_post_enabled", False)
|
||||
self.platforms = config.app.get("upload_post_platforms", ["tiktok", "instagram"])
|
||||
self.auto_upload = config.app.get("upload_post_auto_upload", False)
|
||||
|
||||
def is_configured(self) -> bool:
|
||||
"""Check if Upload-Post is properly configured."""
|
||||
return bool(self.api_key and self.username and self.enabled)
|
||||
|
||||
def upload_video(
|
||||
self,
|
||||
video_path: str,
|
||||
title: str,
|
||||
platforms: list = None,
|
||||
privacy_level: str = "PUBLIC_TO_EVERYONE"
|
||||
) -> dict:
|
||||
"""
|
||||
Upload a video to TikTok and/or Instagram.
|
||||
|
||||
Args:
|
||||
video_path (str): Path to the video file
|
||||
title (str): Video title/caption (max 2200 chars for Instagram)
|
||||
platforms (list): List of platforms ["tiktok", "instagram"]
|
||||
privacy_level (str): Privacy level for the video
|
||||
|
||||
Returns:
|
||||
dict: API response with request_id and status
|
||||
"""
|
||||
if not self.is_configured():
|
||||
logger.warning("Upload-Post is not configured. Skipping cross-post.")
|
||||
return {"success": False, "error": "Upload-Post not configured"}
|
||||
|
||||
if platforms is None:
|
||||
platforms = self.platforms
|
||||
|
||||
if not os.path.exists(video_path):
|
||||
logger.error(f"Video file not found: {video_path}")
|
||||
return {"success": False, "error": f"Video file not found: {video_path}"}
|
||||
|
||||
logger.info(f"Cross-posting video to {', '.join(platforms)} via Upload-Post...")
|
||||
|
||||
try:
|
||||
with open(video_path, 'rb') as video_file:
|
||||
files = {'video': video_file}
|
||||
|
||||
data = {
|
||||
'user': self.username,
|
||||
'title': title[:2200],
|
||||
'privacy_level': privacy_level
|
||||
}
|
||||
|
||||
# Add each platform
|
||||
for i, platform in enumerate(platforms):
|
||||
data[f'platform[{i}]'] = platform
|
||||
|
||||
headers = {
|
||||
'Authorization': f'Apikey {self.api_key}'
|
||||
}
|
||||
|
||||
response = requests.post(
|
||||
f"{self.API_BASE}/api/upload_video",
|
||||
headers=headers,
|
||||
data=data,
|
||||
files=files,
|
||||
timeout=300
|
||||
)
|
||||
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
|
||||
if result.get('success'):
|
||||
logger.info(f"✅ Video cross-posted successfully! Request ID: {result.get('request_id')}")
|
||||
else:
|
||||
logger.warning(f"Cross-post failed: {result.get('message', 'Unknown error')}")
|
||||
|
||||
return result
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.error(f"Failed to cross-post video: {str(e)}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
def check_status(self, request_id: str) -> dict:
|
||||
"""
|
||||
Check the status of an upload request.
|
||||
|
||||
Args:
|
||||
request_id (str): The request ID from upload
|
||||
|
||||
Returns:
|
||||
dict: Status information
|
||||
"""
|
||||
try:
|
||||
headers = {
|
||||
'Authorization': f'Apikey {self.api_key}'
|
||||
}
|
||||
|
||||
response = requests.get(
|
||||
f"{self.API_BASE}/api/status/{request_id}",
|
||||
headers=headers,
|
||||
timeout=30
|
||||
)
|
||||
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.error(f"Failed to check status: {str(e)}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
|
||||
# Singleton instance
|
||||
upload_post_service = UploadPostService()
|
||||
|
||||
|
||||
def cross_post_video(video_path: str, title: str, platforms: list = None) -> dict:
|
||||
"""
|
||||
Convenience function to cross-post a video.
|
||||
|
||||
Args:
|
||||
video_path (str): Path to the video file
|
||||
title (str): Video title/caption
|
||||
platforms (list): List of platforms (defaults to config)
|
||||
|
||||
Returns:
|
||||
dict: API response
|
||||
"""
|
||||
return upload_post_service.upload_video(video_path, title, platforms)
|
||||
73
app/services/utils/video_effects.py
Normal file
73
app/services/utils/video_effects.py
Normal file
@@ -0,0 +1,73 @@
|
||||
from moviepy import Clip, ColorClip, CompositeVideoClip, vfx
|
||||
|
||||
|
||||
# FadeIn
|
||||
def fadein_transition(clip: Clip, t: float) -> Clip:
|
||||
return clip.with_effects([vfx.FadeIn(t)])
|
||||
|
||||
|
||||
# FadeOut
|
||||
def fadeout_transition(clip: Clip, t: float) -> Clip:
|
||||
return clip.with_effects([vfx.FadeOut(t)])
|
||||
|
||||
|
||||
# SlideIn
|
||||
def slidein_transition(clip: Clip, t: float, side: str) -> Clip:
|
||||
width, height = clip.size
|
||||
|
||||
# MoviePy 内置 SlideIn 在当前这条处理链里对全屏素材不稳定,
|
||||
# 会出现“逻辑上应用了转场,但画面几乎看不出变化”的情况。
|
||||
# 这里改成显式黑底 + 位移动画,保证转场效果可见且行为可控。
|
||||
def position(current_time: float):
|
||||
progress = min(max(current_time / max(t, 0.001), 0), 1)
|
||||
|
||||
if side == "left":
|
||||
return (-width + width * progress, 0)
|
||||
if side == "right":
|
||||
return (width - width * progress, 0)
|
||||
if side == "top":
|
||||
return (0, -height + height * progress)
|
||||
if side == "bottom":
|
||||
return (0, height - height * progress)
|
||||
return (0, 0)
|
||||
|
||||
background = ColorClip(size=(width, height), color=(0, 0, 0)).with_duration(
|
||||
clip.duration
|
||||
)
|
||||
moving_clip = clip.with_position(position)
|
||||
return CompositeVideoClip([background, moving_clip], size=(width, height)).with_duration(
|
||||
clip.duration
|
||||
)
|
||||
|
||||
|
||||
# SlideOut
|
||||
def slideout_transition(clip: Clip, t: float, side: str) -> Clip:
|
||||
width, height = clip.size
|
||||
transition_start = max(clip.duration - t, 0)
|
||||
|
||||
# SlideOut 同样改成显式位移,保证片段末尾能稳定滑出画面。
|
||||
def position(current_time: float):
|
||||
if current_time <= transition_start:
|
||||
return (0, 0)
|
||||
|
||||
progress = min(
|
||||
max((current_time - transition_start) / max(t, 0.001), 0), 1
|
||||
)
|
||||
|
||||
if side == "left":
|
||||
return (-width * progress, 0)
|
||||
if side == "right":
|
||||
return (width * progress, 0)
|
||||
if side == "top":
|
||||
return (0, -height * progress)
|
||||
if side == "bottom":
|
||||
return (0, height * progress)
|
||||
return (0, 0)
|
||||
|
||||
background = ColorClip(size=(width, height), color=(0, 0, 0)).with_duration(
|
||||
clip.duration
|
||||
)
|
||||
moving_clip = clip.with_position(position)
|
||||
return CompositeVideoClip([background, moving_clip], size=(width, height)).with_duration(
|
||||
clip.duration
|
||||
)
|
||||
789
app/services/video.py
Normal file
789
app/services/video.py
Normal file
@@ -0,0 +1,789 @@
|
||||
import gc
|
||||
import glob
|
||||
import io
|
||||
import itertools
|
||||
import os
|
||||
import random
|
||||
import shutil
|
||||
import subprocess
|
||||
from contextlib import redirect_stdout
|
||||
from typing import List
|
||||
|
||||
from PIL import Image, ImageFont
|
||||
from loguru import logger
|
||||
from moviepy import (
|
||||
AudioFileClip,
|
||||
ColorClip,
|
||||
CompositeAudioClip,
|
||||
CompositeVideoClip,
|
||||
ImageClip,
|
||||
TextClip,
|
||||
VideoFileClip,
|
||||
afx,
|
||||
)
|
||||
from moviepy.video.tools.subtitles import SubtitlesClip
|
||||
|
||||
from app.models import const
|
||||
from app.models.schema import (
|
||||
MaterialInfo,
|
||||
VideoAspect,
|
||||
VideoConcatMode,
|
||||
VideoParams,
|
||||
VideoTransitionMode,
|
||||
)
|
||||
from app.services.utils import video_effects
|
||||
from app.utils import file_security, utils
|
||||
|
||||
|
||||
class SubClippedVideoClip:
|
||||
def __init__(self, file_path, start_time=None, end_time=None, width=None, height=None, duration=None):
|
||||
self.file_path = file_path
|
||||
self.start_time = start_time
|
||||
self.end_time = end_time
|
||||
self.width = width
|
||||
self.height = height
|
||||
if duration is None:
|
||||
self.duration = end_time - start_time
|
||||
else:
|
||||
self.duration = duration
|
||||
|
||||
def __str__(self):
|
||||
return f"SubClippedVideoClip(file_path={self.file_path}, start_time={self.start_time}, end_time={self.end_time}, duration={self.duration}, width={self.width}, height={self.height})"
|
||||
|
||||
|
||||
audio_codec = "aac"
|
||||
# Docker 里的 ffmpeg/AAC 组合在默认配置下更容易出现音频质量波动,
|
||||
# 这里显式抬高音频码率,避免成片阶段因为默认值过低而引入明显失真。
|
||||
audio_bitrate = "192k"
|
||||
video_codec = "libx264"
|
||||
fps = 30
|
||||
_BGM_EXTENSIONS = (".mp3",)
|
||||
|
||||
|
||||
def get_ffmpeg_binary():
|
||||
# 优先复用用户在 config.toml / 环境变量里显式指定的 ffmpeg,可避免
|
||||
# Windows 便携包、Docker、自定义安装目录等场景下 PATH 不一致。
|
||||
configured_ffmpeg = os.environ.get("IMAGEIO_FFMPEG_EXE")
|
||||
if configured_ffmpeg:
|
||||
return configured_ffmpeg
|
||||
|
||||
system_ffmpeg = shutil.which("ffmpeg")
|
||||
if system_ffmpeg:
|
||||
return system_ffmpeg
|
||||
|
||||
try:
|
||||
import imageio_ffmpeg
|
||||
|
||||
bundled_ffmpeg = imageio_ffmpeg.get_ffmpeg_exe()
|
||||
if bundled_ffmpeg:
|
||||
return bundled_ffmpeg
|
||||
except Exception as exc:
|
||||
logger.warning(f"failed to resolve bundled ffmpeg binary: {str(exc)}")
|
||||
|
||||
return "ffmpeg"
|
||||
|
||||
|
||||
def _escape_ffmpeg_concat_path(file_path: str) -> str:
|
||||
# concat demuxer 使用单引号包裹路径,路径中的单引号需要先转义。
|
||||
return file_path.replace("'", "'\\''")
|
||||
|
||||
|
||||
def concat_video_clips_with_ffmpeg(
|
||||
clip_files: List[str], output_file: str, threads: int, output_dir: str
|
||||
):
|
||||
concat_list_file = os.path.join(output_dir, "ffmpeg-concat-list.txt")
|
||||
with open(concat_list_file, "w", encoding="utf-8") as fp:
|
||||
for clip_file in clip_files:
|
||||
absolute_path = os.path.abspath(clip_file)
|
||||
fp.write(f"file '{_escape_ffmpeg_concat_path(absolute_path)}'\n")
|
||||
|
||||
command = [
|
||||
get_ffmpeg_binary(),
|
||||
"-y",
|
||||
"-f",
|
||||
"concat",
|
||||
"-safe",
|
||||
"0",
|
||||
"-i",
|
||||
concat_list_file,
|
||||
"-c:v",
|
||||
video_codec,
|
||||
"-threads",
|
||||
str(threads or 2),
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
output_file,
|
||||
]
|
||||
|
||||
try:
|
||||
# 使用 ffmpeg 只做一次串联与编码,避免 MoviePy 逐段合并时反复重编码,
|
||||
# 从而降低画质劣化与颜色偏移风险。
|
||||
result = subprocess.run(
|
||||
command,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
error_message = (result.stderr or result.stdout or "").strip()
|
||||
raise RuntimeError(error_message or "ffmpeg concat failed")
|
||||
finally:
|
||||
delete_files(concat_list_file)
|
||||
|
||||
|
||||
def _sanitize_image_file(image_path: str) -> str:
|
||||
# 某些本地图片虽然能被 Pillow 打开,但会因为损坏的 EXIF/eXIf 元数据导致
|
||||
# ImageClip 在解析阶段直接抛异常。这里重新导出一份“干净图片”,把坏元数据剥离掉。
|
||||
image_root, _ = os.path.splitext(image_path)
|
||||
sanitized_path = f"{image_root}.sanitized.png"
|
||||
|
||||
with Image.open(image_path) as image:
|
||||
image.load()
|
||||
# 统一导出为 PNG,避免 JPEG/PNG 不同元数据路径继续把坏块带过去。
|
||||
cleaned_image = Image.new(image.mode, image.size)
|
||||
cleaned_image.putdata(list(image.getdata()))
|
||||
cleaned_image.save(sanitized_path)
|
||||
|
||||
return sanitized_path
|
||||
|
||||
|
||||
def _open_image_clip_with_fallback(image_path: str):
|
||||
# 优先直接打开原始图片;如果因为损坏元数据失败,再尝试生成无元数据副本。
|
||||
try:
|
||||
return ImageClip(image_path), image_path
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
f"failed to open image directly, trying sanitized copy: {image_path}, error: {str(exc)}"
|
||||
)
|
||||
sanitized_path = _sanitize_image_file(image_path)
|
||||
return ImageClip(sanitized_path), sanitized_path
|
||||
|
||||
|
||||
def _open_video_clip_quietly(video_path: str, audio: bool = False) -> VideoFileClip:
|
||||
"""
|
||||
安静地打开视频文件,避免 MoviePy 2.1.x 把 ffmpeg 探测信息直接打印到 stdout。
|
||||
|
||||
背景:
|
||||
当前依赖版本的 `FFMPEG_VideoReader` 内部存在 `print(self.infos)` 和
|
||||
`print(ffmpeg command)`,读取无音轨的中间视频时会输出
|
||||
`audio_found: False`。这只是输入素材 metadata,不代表最终成片没有音频,
|
||||
但会误导 WebUI/终端用户以为生成失败。
|
||||
|
||||
实现:
|
||||
1. 只在打开 VideoFileClip 的短窗口内重定向 stdout;
|
||||
2. 默认 `audio=False`,因为项目视频素材阶段不需要保留素材原声,
|
||||
最终音频会在 `generate_video()` 阶段统一挂载;
|
||||
3. 如果依赖库确实输出了内容,降级为 debug 日志,便于必要时排查。
|
||||
"""
|
||||
captured_stdout = io.StringIO()
|
||||
with redirect_stdout(captured_stdout):
|
||||
print("video_path", video_path)
|
||||
clip = VideoFileClip(video_path, audio=audio)
|
||||
|
||||
moviepy_stdout = captured_stdout.getvalue().strip()
|
||||
if moviepy_stdout:
|
||||
logger.debug(
|
||||
"suppressed MoviePy video reader stdout for "
|
||||
f"{video_path}, chars: {len(moviepy_stdout)}"
|
||||
)
|
||||
|
||||
return clip
|
||||
|
||||
|
||||
def close_clip(clip):
|
||||
if clip is None:
|
||||
return
|
||||
|
||||
try:
|
||||
# close main resources
|
||||
if hasattr(clip, 'reader') and clip.reader is not None:
|
||||
clip.reader.close()
|
||||
|
||||
# close audio resources
|
||||
if hasattr(clip, 'audio') and clip.audio is not None:
|
||||
if hasattr(clip.audio, 'reader') and clip.audio.reader is not None:
|
||||
clip.audio.reader.close()
|
||||
del clip.audio
|
||||
|
||||
# close mask resources
|
||||
if hasattr(clip, 'mask') and clip.mask is not None:
|
||||
if hasattr(clip.mask, 'reader') and clip.mask.reader is not None:
|
||||
clip.mask.reader.close()
|
||||
del clip.mask
|
||||
|
||||
# handle child clips in composite clips
|
||||
if hasattr(clip, 'clips') and clip.clips:
|
||||
for child_clip in clip.clips:
|
||||
if child_clip is not clip: # avoid possible circular references
|
||||
close_clip(child_clip)
|
||||
|
||||
# clear clip list
|
||||
if hasattr(clip, 'clips'):
|
||||
clip.clips = []
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"failed to close clip: {str(e)}")
|
||||
|
||||
del clip
|
||||
gc.collect()
|
||||
|
||||
|
||||
def delete_files(files: List[str] | str):
|
||||
if isinstance(files, str):
|
||||
files = [files]
|
||||
|
||||
for file in files:
|
||||
try:
|
||||
os.remove(file)
|
||||
except Exception as e:
|
||||
logger.debug(f"failed to delete file {file}: {str(e)}")
|
||||
|
||||
|
||||
def _resolve_bgm_file_path(song_dir: str, bgm_file: str) -> str:
|
||||
# 背景音乐只允许读取 resource/songs 目录内的文件,避免用户输入任意路径后
|
||||
# 被 MoviePy 打开。这里兼容两种常见输入:
|
||||
# 1. output000.mp3:来自 BGM 列表或用户只填写文件名
|
||||
# 2. ./resource/songs/output000.mp3:用户按项目目录结构填写的相对路径
|
||||
# 两种写法最终都会再次通过 resource/songs 白名单校验,不能绕过目录限制。
|
||||
try:
|
||||
return file_security.resolve_path_within_directory(song_dir, bgm_file)
|
||||
except ValueError as song_dir_exc:
|
||||
if os.path.isabs(bgm_file):
|
||||
raise song_dir_exc
|
||||
|
||||
project_relative_file = os.path.join(utils.root_dir(), bgm_file)
|
||||
try:
|
||||
return file_security.resolve_path_within_directory(
|
||||
song_dir, project_relative_file
|
||||
)
|
||||
except ValueError as root_dir_exc:
|
||||
raise ValueError(str(root_dir_exc)) from song_dir_exc
|
||||
|
||||
|
||||
def get_bgm_file(bgm_type: str = "random", bgm_file: str = ""):
|
||||
if not bgm_type:
|
||||
return ""
|
||||
|
||||
if bgm_file:
|
||||
song_dir = utils.song_dir()
|
||||
try:
|
||||
resolved_bgm_file = _resolve_bgm_file_path(song_dir, bgm_file)
|
||||
except ValueError as exc:
|
||||
# API 请求里的 bgm_file 来自用户输入,不能直接把任意绝对路径交给
|
||||
# MoviePy 打开。这里强制限制到 resource/songs 目录,阻止读取
|
||||
# /etc/passwd、配置文件、密钥等非背景音乐文件。
|
||||
logger.warning(
|
||||
f"reject unsafe bgm file: {bgm_file}, song_dir: {song_dir}, error: {str(exc)}"
|
||||
)
|
||||
return ""
|
||||
|
||||
if not resolved_bgm_file.lower().endswith(_BGM_EXTENSIONS):
|
||||
logger.warning(f"reject unsupported bgm file extension: {resolved_bgm_file}")
|
||||
return ""
|
||||
|
||||
return resolved_bgm_file
|
||||
|
||||
if bgm_type == "random":
|
||||
suffix = "*.mp3"
|
||||
song_dir = utils.song_dir()
|
||||
files = glob.glob(os.path.join(song_dir, suffix))
|
||||
# 当背景音乐目录为空时,直接回退为“不使用 BGM”,避免 random.choice([]) 抛异常。
|
||||
if not files:
|
||||
logger.warning(f"no bgm files found in song directory: {song_dir}")
|
||||
return ""
|
||||
return random.choice(files)
|
||||
|
||||
return ""
|
||||
|
||||
|
||||
def combine_videos(
|
||||
combined_video_path: str,
|
||||
video_paths: List[str],
|
||||
audio_file: str,
|
||||
video_aspect: VideoAspect = VideoAspect.portrait,
|
||||
video_concat_mode: VideoConcatMode = VideoConcatMode.random,
|
||||
video_transition_mode: VideoTransitionMode = None,
|
||||
max_clip_duration: int = 5,
|
||||
threads: int = 2,
|
||||
) -> str:
|
||||
audio_clip = AudioFileClip(audio_file)
|
||||
try:
|
||||
# 这里只需要读取旁白音频时长来决定素材视频拼接长度;后续不会再使用
|
||||
# audio_clip。读取完成后立即关闭,避免早退或异常路径泄漏文件句柄。
|
||||
audio_duration = audio_clip.duration
|
||||
finally:
|
||||
close_clip(audio_clip)
|
||||
logger.info(f"audio duration: {audio_duration} seconds")
|
||||
logger.info(f"maximum clip duration: {max_clip_duration} seconds")
|
||||
|
||||
# 兼容 API 直接调用时未传转场模式的情况,避免后续访问 .value 时崩溃。
|
||||
transition_value = getattr(video_transition_mode, "value", video_transition_mode)
|
||||
output_dir = os.path.dirname(combined_video_path)
|
||||
|
||||
aspect = VideoAspect(video_aspect)
|
||||
video_width, video_height = aspect.to_resolution()
|
||||
|
||||
processed_clips = []
|
||||
subclipped_items = []
|
||||
video_duration = 0
|
||||
for video_path in video_paths:
|
||||
clip = _open_video_clip_quietly(video_path)
|
||||
clip_duration = clip.duration
|
||||
clip_w, clip_h = clip.size
|
||||
close_clip(clip)
|
||||
|
||||
start_time = 0
|
||||
|
||||
while start_time < clip_duration:
|
||||
end_time = min(start_time + max_clip_duration, clip_duration)
|
||||
|
||||
# 保留所有有效分段。
|
||||
# 这样既不会丢掉“整段视频本身就短于 max_clip_duration”的素材,
|
||||
# 也不会吞掉长视频最后剩下的一小段尾部内容。
|
||||
if end_time > start_time:
|
||||
subclipped_items.append(
|
||||
SubClippedVideoClip(
|
||||
file_path=video_path,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
width=clip_w,
|
||||
height=clip_h,
|
||||
)
|
||||
)
|
||||
|
||||
start_time = end_time
|
||||
if video_concat_mode.value == VideoConcatMode.sequential.value:
|
||||
break
|
||||
|
||||
# random subclipped_items order
|
||||
if video_concat_mode.value == VideoConcatMode.random.value:
|
||||
random.shuffle(subclipped_items)
|
||||
|
||||
logger.debug(f"total subclipped items: {len(subclipped_items)}")
|
||||
|
||||
# Add downloaded clips over and over until the duration of the audio (max_duration) has been reached
|
||||
for i, subclipped_item in enumerate(subclipped_items):
|
||||
if video_duration > audio_duration:
|
||||
break
|
||||
|
||||
logger.debug(
|
||||
f"processing clip {i + 1}: {subclipped_item.width}x{subclipped_item.height}, current duration: {video_duration:.2f}s, remaining: {audio_duration - video_duration:.2f}s")
|
||||
|
||||
try:
|
||||
clip = _open_video_clip_quietly(subclipped_item.file_path).subclipped(
|
||||
subclipped_item.start_time, subclipped_item.end_time
|
||||
)
|
||||
clip_duration = clip.duration
|
||||
# Not all videos are same size, so we need to resize them
|
||||
clip_w, clip_h = clip.size
|
||||
if clip_w != video_width or clip_h != video_height:
|
||||
clip_ratio = clip.w / clip.h
|
||||
video_ratio = video_width / video_height
|
||||
logger.debug(
|
||||
f"resizing clip, source: {clip_w}x{clip_h}, ratio: {clip_ratio:.2f}, target: {video_width}x{video_height}, ratio: {video_ratio:.2f}")
|
||||
|
||||
if clip_ratio == video_ratio:
|
||||
clip = clip.resized(new_size=(video_width, video_height))
|
||||
else:
|
||||
if clip_ratio > video_ratio:
|
||||
scale_factor = video_width / clip_w
|
||||
else:
|
||||
scale_factor = video_height / clip_h
|
||||
|
||||
new_width = int(clip_w * scale_factor)
|
||||
new_height = int(clip_h * scale_factor)
|
||||
|
||||
background = ColorClip(size=(video_width, video_height), color=(0, 0, 0)).with_duration(
|
||||
clip_duration)
|
||||
clip_resized = clip.resized(new_size=(new_width, new_height)).with_position("center")
|
||||
clip = CompositeVideoClip([background, clip_resized])
|
||||
|
||||
shuffle_side = random.choice(["left", "right", "top", "bottom"])
|
||||
if transition_value in (None, VideoTransitionMode.none.value):
|
||||
clip = clip
|
||||
elif transition_value == VideoTransitionMode.fade_in.value:
|
||||
clip = video_effects.fadein_transition(clip, 1)
|
||||
elif transition_value == VideoTransitionMode.fade_out.value:
|
||||
clip = video_effects.fadeout_transition(clip, 1)
|
||||
elif transition_value == VideoTransitionMode.slide_in.value:
|
||||
clip = video_effects.slidein_transition(clip, 1, shuffle_side)
|
||||
elif transition_value == VideoTransitionMode.slide_out.value:
|
||||
clip = video_effects.slideout_transition(clip, 1, shuffle_side)
|
||||
elif transition_value == VideoTransitionMode.shuffle.value:
|
||||
transition_funcs = [
|
||||
lambda c: video_effects.fadein_transition(c, 1),
|
||||
lambda c: video_effects.fadeout_transition(c, 1),
|
||||
lambda c: video_effects.slidein_transition(c, 1, shuffle_side),
|
||||
lambda c: video_effects.slideout_transition(c, 1, shuffle_side),
|
||||
]
|
||||
shuffle_transition = random.choice(transition_funcs)
|
||||
clip = shuffle_transition(clip)
|
||||
|
||||
if clip.duration > max_clip_duration:
|
||||
clip = clip.subclipped(0, max_clip_duration)
|
||||
|
||||
# wirte clip to temp file
|
||||
clip_file = f"{output_dir}/temp-clip-{i + 1}.mp4"
|
||||
clip.write_videofile(clip_file, logger=None, fps=fps, codec=video_codec)
|
||||
|
||||
# Store clip duration before closing
|
||||
clip_duration_saved = clip.duration
|
||||
close_clip(clip)
|
||||
|
||||
processed_clips.append(
|
||||
SubClippedVideoClip(file_path=clip_file, duration=clip_duration_saved, width=clip_w, height=clip_h))
|
||||
video_duration += clip_duration_saved
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"failed to process clip: {str(e)}")
|
||||
|
||||
# loop processed clips until the video duration matches or exceeds the audio duration.
|
||||
if video_duration < audio_duration:
|
||||
logger.warning(
|
||||
f"video duration ({video_duration:.2f}s) is shorter than audio duration ({audio_duration:.2f}s), looping clips to match audio length.")
|
||||
base_clips = processed_clips.copy()
|
||||
for clip in itertools.cycle(base_clips):
|
||||
if video_duration >= audio_duration:
|
||||
break
|
||||
processed_clips.append(clip)
|
||||
video_duration += clip.duration
|
||||
logger.info(
|
||||
f"video duration: {video_duration:.2f}s, audio duration: {audio_duration:.2f}s, looped {len(processed_clips) - len(base_clips)} clips")
|
||||
|
||||
# merge video clips progressively, avoid loading all videos at once to avoid memory overflow
|
||||
logger.info("starting clip merging process")
|
||||
if not processed_clips:
|
||||
logger.warning("no clips available for merging")
|
||||
return combined_video_path
|
||||
|
||||
# if there is only one clip, use it directly
|
||||
if len(processed_clips) == 1:
|
||||
logger.info("using single clip directly")
|
||||
shutil.copy(processed_clips[0].file_path, combined_video_path)
|
||||
delete_files([processed_clips[0].file_path])
|
||||
logger.info("video combining completed")
|
||||
return combined_video_path
|
||||
|
||||
clip_files = [clip.file_path for clip in processed_clips]
|
||||
logger.info(f"concatenating {len(clip_files)} clips with ffmpeg")
|
||||
concat_video_clips_with_ffmpeg(
|
||||
clip_files=clip_files,
|
||||
output_file=combined_video_path,
|
||||
threads=threads,
|
||||
output_dir=output_dir,
|
||||
)
|
||||
|
||||
# clean temp files
|
||||
delete_files(clip_files)
|
||||
|
||||
logger.info("video combining completed")
|
||||
return combined_video_path
|
||||
|
||||
|
||||
def wrap_text(text, max_width, font="Arial", fontsize=60):
|
||||
# 字幕换行必须在真正创建 TextClip 前完成,否则 MoviePy 只会按原始文本
|
||||
# 计算渲染区域。这里用 PIL 按当前字体和字号测量宽度,确保每一行都尽量
|
||||
# 控制在视频可用宽度内,避免大字号或中文长句直接溢出画面。
|
||||
font = ImageFont.truetype(font, fontsize)
|
||||
max_width = int(max_width)
|
||||
|
||||
def get_text_size(inner_text):
|
||||
inner_text = inner_text.strip()
|
||||
if not inner_text:
|
||||
return 0, fontsize
|
||||
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, height
|
||||
|
||||
def split_long_token(token):
|
||||
# 当一个 token 本身就超宽时(常见于中文无空格长句,或英文超长单词),
|
||||
# 退化为字符级拆分。关键点是:检测到 candidate 超宽时,先提交上一个
|
||||
# 仍然合法的 current,再把当前字符放入下一行,不能把超宽字符塞回上一行。
|
||||
lines = []
|
||||
current = ""
|
||||
for char in token:
|
||||
candidate = f"{current}{char}"
|
||||
candidate_width, _ = get_text_size(candidate)
|
||||
if candidate_width <= max_width or not current:
|
||||
current = candidate
|
||||
continue
|
||||
lines.append(current)
|
||||
current = char
|
||||
if current:
|
||||
lines.append(current)
|
||||
return lines
|
||||
|
||||
lines = []
|
||||
current = ""
|
||||
words = text.split(" ")
|
||||
for word in words:
|
||||
candidate = f"{current} {word}".strip() if current else word
|
||||
candidate_width, _ = get_text_size(candidate)
|
||||
if candidate_width <= max_width:
|
||||
current = candidate
|
||||
continue
|
||||
|
||||
if current:
|
||||
lines.append(current)
|
||||
|
||||
word_width, _ = get_text_size(word)
|
||||
if word_width <= max_width:
|
||||
current = word
|
||||
else:
|
||||
lines.extend(split_long_token(word))
|
||||
current = ""
|
||||
|
||||
if current:
|
||||
lines.append(current)
|
||||
|
||||
result = "\n".join(line.strip() for line in lines if line.strip()).strip()
|
||||
height = len(lines) * height
|
||||
return result, height
|
||||
|
||||
|
||||
def generate_video(
|
||||
video_path: str,
|
||||
audio_path: str,
|
||||
subtitle_path: str,
|
||||
output_file: str,
|
||||
params: VideoParams,
|
||||
):
|
||||
aspect = VideoAspect(params.video_aspect)
|
||||
video_width, video_height = aspect.to_resolution()
|
||||
|
||||
logger.info(f"generating video: {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}")
|
||||
|
||||
# https://github.com/harry0703/MoneyPrinterTurbo/issues/217
|
||||
# PermissionError: [WinError 32] The process cannot access the file because it is being used by another process: 'final-1.mp4.tempTEMP_MPY_wvf_snd.mp3'
|
||||
# write into the same directory as the output file
|
||||
output_dir = os.path.dirname(output_file)
|
||||
|
||||
font_path = ""
|
||||
if params.subtitle_enabled:
|
||||
if not params.font_name:
|
||||
params.font_name = "STHeitiMedium.ttc"
|
||||
font_path = os.path.join(utils.font_dir(), params.font_name)
|
||||
if os.name == "nt":
|
||||
font_path = font_path.replace("\\", "/")
|
||||
|
||||
logger.info(f" ⑤ font: {font_path}")
|
||||
|
||||
def resolve_subtitle_background_color():
|
||||
# 兼容历史参数:API 里 `text_background_color` 既可能是布尔值,
|
||||
# 也可能是实际颜色字符串。统一在这里归一化,避免把 True/False
|
||||
# 直接传给 TextClip 后出现不可预期的渲染结果。
|
||||
if isinstance(params.text_background_color, bool):
|
||||
return "#000000" if params.text_background_color else None
|
||||
return params.text_background_color
|
||||
|
||||
def create_text_clip(subtitle_item):
|
||||
params.font_size = int(params.font_size)
|
||||
params.stroke_width = int(params.stroke_width)
|
||||
phrase = subtitle_item[1]
|
||||
max_width = video_width * 0.9
|
||||
wrapped_txt, txt_height = wrap_text(
|
||||
phrase, max_width=max_width, font=font_path, fontsize=params.font_size
|
||||
)
|
||||
interline = int(params.font_size * 0.25)
|
||||
line_count = wrapped_txt.count("\n") + 1
|
||||
vertical_padding = int(params.font_size * 0.35)
|
||||
# MoviePy 在 `method=label` 下会自动收缩文本框高度,遇到多行字幕、
|
||||
# 描边或背景色时,容易把最后一行的下半部分裁掉。这里显式传入
|
||||
# 一个更保守的高度,把行间距和额外上下留白一并算进去,保证字幕
|
||||
# 背景框与文字本身都能完整渲染出来。
|
||||
size = (
|
||||
int(max_width),
|
||||
int(txt_height + vertical_padding + (interline * line_count)),
|
||||
)
|
||||
|
||||
_clip = TextClip(
|
||||
text=wrapped_txt,
|
||||
font=font_path,
|
||||
font_size=params.font_size,
|
||||
color=params.text_fore_color,
|
||||
bg_color=resolve_subtitle_background_color(),
|
||||
stroke_color=params.stroke_color,
|
||||
stroke_width=params.stroke_width,
|
||||
interline=interline,
|
||||
size=size,
|
||||
text_align="center",
|
||||
)
|
||||
duration = subtitle_item[0][1] - subtitle_item[0][0]
|
||||
_clip = _clip.with_start(subtitle_item[0][0])
|
||||
_clip = _clip.with_end(subtitle_item[0][1])
|
||||
_clip = _clip.with_duration(duration)
|
||||
if params.subtitle_position == "bottom":
|
||||
_clip = _clip.with_position(("center", video_height * 0.95 - _clip.h))
|
||||
elif params.subtitle_position == "top":
|
||||
_clip = _clip.with_position(("center", video_height * 0.05))
|
||||
elif params.subtitle_position == "custom":
|
||||
# Ensure the subtitle is fully within the screen bounds
|
||||
margin = 10 # Additional margin, in pixels
|
||||
max_y = video_height - _clip.h - margin
|
||||
min_y = margin
|
||||
custom_y = (video_height - _clip.h) * (params.custom_position / 100)
|
||||
custom_y = max(
|
||||
min_y, min(custom_y, max_y)
|
||||
) # Constrain the y value within the valid range
|
||||
_clip = _clip.with_position(("center", custom_y))
|
||||
else: # center
|
||||
_clip = _clip.with_position(("center", "center"))
|
||||
return _clip
|
||||
|
||||
video_clip = _open_video_clip_quietly(video_path)
|
||||
audio_clip = AudioFileClip(audio_path).with_effects(
|
||||
[afx.MultiplyVolume(params.voice_volume)]
|
||||
)
|
||||
|
||||
def make_textclip(text):
|
||||
return TextClip(
|
||||
text=text,
|
||||
font=font_path,
|
||||
font_size=params.font_size,
|
||||
)
|
||||
|
||||
if subtitle_path and os.path.exists(subtitle_path):
|
||||
sub = SubtitlesClip(
|
||||
subtitles=subtitle_path, encoding="utf-8", make_textclip=make_textclip
|
||||
)
|
||||
text_clips = []
|
||||
for item in sub.subtitles:
|
||||
clip = create_text_clip(subtitle_item=item)
|
||||
text_clips.append(clip)
|
||||
video_clip = CompositeVideoClip([video_clip, *text_clips])
|
||||
|
||||
bgm_file = get_bgm_file(bgm_type=params.bgm_type, bgm_file=params.bgm_file)
|
||||
if bgm_file:
|
||||
try:
|
||||
bgm_clip = AudioFileClip(bgm_file).with_effects(
|
||||
[
|
||||
afx.MultiplyVolume(params.bgm_volume),
|
||||
afx.AudioFadeOut(3),
|
||||
afx.AudioLoop(duration=video_clip.duration),
|
||||
]
|
||||
)
|
||||
audio_clip = CompositeAudioClip([audio_clip, bgm_clip])
|
||||
except Exception as e:
|
||||
logger.error(f"failed to add bgm: {str(e)}")
|
||||
|
||||
video_clip = video_clip.with_audio(audio_clip)
|
||||
# 显式沿用输入音频的采样率;如果取不到,再回退到 MoviePy 默认的 44100Hz。
|
||||
# 这样可以减少不同运行环境,尤其是 Docker 环境中再次重采样带来的音质波动。
|
||||
output_audio_fps = int(getattr(audio_clip, "fps", 0) or 44100)
|
||||
video_clip.write_videofile(
|
||||
output_file,
|
||||
audio_codec=audio_codec,
|
||||
audio_fps=output_audio_fps,
|
||||
audio_bitrate=audio_bitrate,
|
||||
temp_audiofile_path=output_dir,
|
||||
threads=params.n_threads or 2,
|
||||
logger=None,
|
||||
fps=fps,
|
||||
)
|
||||
video_clip.close()
|
||||
del video_clip
|
||||
|
||||
|
||||
def preprocess_video(materials: List[MaterialInfo], clip_duration=4):
|
||||
# WebUI 在某些二次生成场景下可能传入空素材列表,这里直接返回空结果,避免抛出 NoneType 异常。
|
||||
if not materials:
|
||||
return []
|
||||
|
||||
# 仅返回通过预处理校验的素材,避免低分辨率图片继续进入后续的视频合成流程。
|
||||
valid_materials = []
|
||||
local_videos_dir = utils.storage_dir("local_videos", create=True)
|
||||
|
||||
for material in materials:
|
||||
if not material.url:
|
||||
continue
|
||||
|
||||
try:
|
||||
material_source_path = file_security.resolve_path_within_directory(
|
||||
local_videos_dir, material.url
|
||||
)
|
||||
except ValueError as exc:
|
||||
# local video_source 的素材路径来自 API 参数,必须限制在专用素材目录。
|
||||
# 允许用户传文件名,也兼容历史返回的绝对路径,但不允许逃逸到系统
|
||||
# 其他目录,避免任意文件读取或通过 MoviePy 探测本地敏感文件。
|
||||
logger.warning(
|
||||
f"skip unsafe local material: {material.url}, "
|
||||
f"local_videos_dir: {local_videos_dir}, error: {str(exc)}"
|
||||
)
|
||||
continue
|
||||
|
||||
ext = utils.parse_extension(material_source_path)
|
||||
try:
|
||||
# 图片素材直接按图片方式读取,避免先走 VideoFileClip 误判后触发不稳定的回退分支。
|
||||
if ext in const.FILE_TYPE_IMAGES:
|
||||
clip, material_source_path = _open_image_clip_with_fallback(
|
||||
material_source_path
|
||||
)
|
||||
else:
|
||||
clip = _open_video_clip_quietly(material_source_path)
|
||||
except Exception:
|
||||
# 非标准扩展名或探测失败时再回退到图片模式,兼容历史上直接传本地图片路径的情况。
|
||||
try:
|
||||
clip, material_source_path = _open_image_clip_with_fallback(
|
||||
material_source_path
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
f"skip unreadable local material: {material.url}, error: {str(exc)}"
|
||||
)
|
||||
continue
|
||||
try:
|
||||
width = clip.size[0]
|
||||
height = clip.size[1]
|
||||
if width < 480 or height < 480:
|
||||
logger.warning(f"low resolution material: {width}x{height}, minimum 480x480 required")
|
||||
# 探测到低分辨率素材后立即关闭资源,并且不要把该素材返回给后续流程。
|
||||
close_clip(clip)
|
||||
continue
|
||||
|
||||
if ext in const.FILE_TYPE_IMAGES:
|
||||
logger.info(f"processing image: {material_source_path}")
|
||||
# 探测尺寸时已经打开过一次素材,这里先释放探测句柄,再重新创建用于导出的图片 clip。
|
||||
close_clip(clip)
|
||||
# Create an image clip and set its duration to 3 seconds
|
||||
clip = (
|
||||
ImageClip(material_source_path)
|
||||
.with_duration(clip_duration)
|
||||
.with_position("center")
|
||||
)
|
||||
# Apply a zoom effect using the resize method.
|
||||
# A lambda function is used to make the zoom effect dynamic over time.
|
||||
# The zoom effect starts from the original size and gradually scales up to 120%.
|
||||
# t represents the current time, and clip.duration is the total duration of the clip (3 seconds).
|
||||
# Note: 1 represents 100% size, so 1.2 represents 120% size.
|
||||
zoom_clip = clip.resized(
|
||||
lambda t: 1 + (clip_duration * 0.03) * (t / clip.duration)
|
||||
)
|
||||
|
||||
# Optionally, create a composite video clip containing the zoomed clip.
|
||||
# This is useful when you want to add other elements to the video.
|
||||
final_clip = CompositeVideoClip([zoom_clip])
|
||||
|
||||
# Output the video to a file.
|
||||
video_file = f"{material_source_path}.mp4"
|
||||
final_clip.write_videofile(video_file, fps=30, logger=None)
|
||||
close_clip(clip)
|
||||
close_clip(final_clip)
|
||||
material.url = video_file
|
||||
logger.success(f"image processed: {video_file}")
|
||||
else:
|
||||
# 普通视频素材只需要读取尺寸做校验,校验完成后立即释放句柄即可。
|
||||
close_clip(clip)
|
||||
except Exception:
|
||||
close_clip(clip)
|
||||
raise
|
||||
|
||||
valid_materials.append(material)
|
||||
|
||||
return valid_materials
|
||||
2351
app/services/voice.py
Normal file
2351
app/services/voice.py
Normal file
File diff suppressed because it is too large
Load Diff
39
app/utils/file_security.py
Normal file
39
app/utils/file_security.py
Normal file
@@ -0,0 +1,39 @@
|
||||
import os
|
||||
from asyncio import log
|
||||
|
||||
|
||||
def resolve_path_within_directory(
|
||||
base_dir: str,
|
||||
unsafe_path: str,
|
||||
*,
|
||||
require_file: bool = True,
|
||||
) -> str:
|
||||
# 用户传入的路径可能是文件名、相对路径、绝对路径,也可能夹带 `../`。
|
||||
# 这里统一解析成真实路径,并用 commonpath 判断它是否仍在允许目录内。
|
||||
# 这样比简单判断字符串前缀可靠,可以覆盖符号链接、重复分隔符、相对路径
|
||||
# 等场景,适用于上传目录、素材目录、任务产物目录这类白名单目录。
|
||||
if not unsafe_path:
|
||||
raise ValueError("empty path is not allowed")
|
||||
|
||||
base_dir_real = os.path.realpath(base_dir)
|
||||
candidate_path = unsafe_path
|
||||
|
||||
if not os.path.isabs(candidate_path):
|
||||
candidate_path = os.path.join(base_dir_real, candidate_path)
|
||||
|
||||
|
||||
resolved_path = os.path.realpath(candidate_path)
|
||||
|
||||
try:
|
||||
common_path = os.path.commonpath([base_dir_real, resolved_path])
|
||||
except ValueError as exc:
|
||||
# Windows 下不同盘符会触发 ValueError,这类路径一定不属于允许目录。
|
||||
raise ValueError("path is outside the allowed directory") from exc
|
||||
|
||||
if common_path != base_dir_real:
|
||||
raise ValueError("path is outside the allowed directory")
|
||||
|
||||
if require_file and not os.path.isfile(resolved_path):
|
||||
raise ValueError("file does not exist")
|
||||
|
||||
return resolved_path
|
||||
236
app/utils/utils.py
Normal file
236
app/utils/utils.py
Normal file
@@ -0,0 +1,236 @@
|
||||
import json
|
||||
import locale
|
||||
import os
|
||||
from pathlib import Path
|
||||
import threading
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from app.models import const
|
||||
|
||||
|
||||
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):
|
||||
try:
|
||||
# Define a helper function to handle different types of objects
|
||||
def serialize(o):
|
||||
# If the object is a serializable type, return it directly
|
||||
if isinstance(o, (int, float, bool, str)) or o is None:
|
||||
return o
|
||||
# If the object is binary data, convert it to a base64-encoded string
|
||||
elif isinstance(o, bytes):
|
||||
return "*** binary data ***"
|
||||
# If the object is a dictionary, recursively process each key-value pair
|
||||
elif isinstance(o, dict):
|
||||
return {k: serialize(v) for k, v in o.items()}
|
||||
# If the object is a list or tuple, recursively process each element
|
||||
elif isinstance(o, (list, tuple)):
|
||||
return [serialize(item) for item in o]
|
||||
# If the object is a custom type, attempt to return its __dict__ attribute
|
||||
elif hasattr(o, "__dict__"):
|
||||
return serialize(o.__dict__)
|
||||
# Return None for other cases (or choose to raise an exception)
|
||||
else:
|
||||
return None
|
||||
|
||||
# Use the serialize function to process the input object
|
||||
serialized_obj = serialize(obj)
|
||||
|
||||
# Serialize the processed object into a JSON string
|
||||
return json.dumps(serialized_obj, ensure_ascii=False, indent=4)
|
||||
except Exception as e:
|
||||
logger.error(f"failed to serialize object to json: {str(e)}")
|
||||
return None
|
||||
|
||||
|
||||
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 = "", create: bool = False):
|
||||
d = os.path.join(root_dir(), "storage")
|
||||
if sub_dir:
|
||||
d = os.path.join(d, sub_dir)
|
||||
if create and not os.path.exists(d):
|
||||
os.makedirs(d)
|
||||
|
||||
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("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("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("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}", exc_info=True)
|
||||
|
||||
thread = threading.Thread(target=run, daemon=False)
|
||||
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 = ""
|
||||
|
||||
previous_char = ""
|
||||
next_char = ""
|
||||
for i in range(len(s)):
|
||||
char = s[i]
|
||||
if char == "\n":
|
||||
result.append(txt.strip())
|
||||
txt = ""
|
||||
continue
|
||||
|
||||
if i > 0:
|
||||
previous_char = s[i - 1]
|
||||
if i < len(s) - 1:
|
||||
next_char = s[i + 1]
|
||||
|
||||
if char == "." and previous_char.isdigit() and next_char.isdigit():
|
||||
# # In the case of "withdraw 10,000, charged at 2.5% fee", the dot in "2.5" should not be treated as a line break marker
|
||||
txt += char
|
||||
continue
|
||||
|
||||
if char == "," and previous_char.isdigit() and next_char.isdigit():
|
||||
# 英文数字里的千分位逗号不是断句符,例如 "1,000 years"。
|
||||
# Edge TTS 的 word boundary 通常会把这种数字整体作为连续内容返回;
|
||||
# 如果这里拆成 "1" 和 "000 years",后续字幕聚合会无法匹配脚本原文,
|
||||
# 进而错误回退到 Whisper。
|
||||
txt += char
|
||||
continue
|
||||
|
||||
if char not in const.PUNCTUATIONS:
|
||||
txt += char
|
||||
else:
|
||||
result.append(txt.strip())
|
||||
txt = ""
|
||||
result.append(txt.strip())
|
||||
# filter empty string
|
||||
result = list(filter(None, result))
|
||||
return result
|
||||
|
||||
|
||||
def md5(text):
|
||||
import hashlib
|
||||
|
||||
return hashlib.md5(text.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def get_system_locale():
|
||||
try:
|
||||
loc = locale.getdefaultlocale()
|
||||
# zh_CN, zh_TW return zh
|
||||
# en_US, en_GB return en
|
||||
language_code = loc[0].split("_")[0]
|
||||
return language_code
|
||||
except Exception:
|
||||
return "en"
|
||||
|
||||
|
||||
def load_locales(i18n_dir):
|
||||
_locales = {}
|
||||
for root, dirs, files in os.walk(i18n_dir):
|
||||
for file in files:
|
||||
if file.endswith(".json"):
|
||||
lang = file.split(".")[0]
|
||||
with open(os.path.join(root, file), "r", encoding="utf-8") as f:
|
||||
_locales[lang] = json.loads(f.read())
|
||||
return _locales
|
||||
|
||||
|
||||
def parse_extension(filename):
|
||||
return Path(filename).suffix.lower().lstrip('.')
|
||||
Reference in New Issue
Block a user