初始化提交

This commit is contained in:
lvyulong
2026-06-12 14:59:20 +08:00
parent c1d00901a8
commit 00718f47f3
85 changed files with 15799 additions and 0 deletions

0
app/__init__.py Normal file
View File

82
app/asgi.py Normal file
View 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
View 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
View 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
View 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}",
)

View 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()

View 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()

View 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
View 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"

View 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
View 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
View 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
View File

25
app/models/const.py Normal file
View 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
View 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
View 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
View 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
View File

714
app/services/llm.py Normal file
View 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
View 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
View 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
View 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
View 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
View 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)

View 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
View 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

File diff suppressed because it is too large Load Diff

View 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
View 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('.')