初始化提交

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