758 lines
26 KiB
Python
758 lines
26 KiB
Python
from __future__ import annotations
|
||
|
||
import argparse
|
||
import json
|
||
import math
|
||
import os
|
||
import re
|
||
import shutil
|
||
from typing import TYPE_CHECKING, Sequence
|
||
from uuid import UUID, uuid4
|
||
|
||
from loguru import logger
|
||
|
||
if TYPE_CHECKING:
|
||
from app.models.schema import VideoParams
|
||
|
||
|
||
DEFAULT_VOICE_NAME = "zh-CN-XiaoxiaoNeural-Female"
|
||
_PIPELINE_STAGES = ("script", "terms", "audio", "subtitle", "materials", "video")
|
||
_CUSTOM_AUDIO_EXTENSIONS = {".mp3", ".wav", ".m4a", ".aac", ".flac", ".ogg"}
|
||
|
||
|
||
class _CliHelpFormatter(
|
||
argparse.ArgumentDefaultsHelpFormatter,
|
||
argparse.RawDescriptionHelpFormatter,
|
||
):
|
||
"""在保留多行示例排版的同时,自动展示有意义的默认值。"""
|
||
|
||
def _get_help_string(self, action):
|
||
help_text = action.help or ""
|
||
if (
|
||
"%(default)" not in help_text
|
||
and action.default not in (None, "", argparse.SUPPRESS)
|
||
and action.option_strings
|
||
and "default:" not in help_text.lower()
|
||
):
|
||
help_text += " (default: %(default)s)"
|
||
return help_text
|
||
|
||
|
||
def _positive_int(value: str) -> int:
|
||
parsed = int(value)
|
||
if parsed < 1:
|
||
raise argparse.ArgumentTypeError(f"value must be >= 1, got {parsed}")
|
||
return parsed
|
||
|
||
|
||
def _paragraph_count(value: str) -> int:
|
||
parsed = int(value)
|
||
if parsed < 1 or parsed > 10:
|
||
raise argparse.ArgumentTypeError(
|
||
f"paragraph-number must be between 1 and 10, got {parsed}"
|
||
)
|
||
return parsed
|
||
|
||
|
||
def _non_negative_float(value: str) -> float:
|
||
parsed = float(value)
|
||
if not math.isfinite(parsed) or parsed < 0:
|
||
raise argparse.ArgumentTypeError(f"value must be a finite number >= 0, got {value!r}")
|
||
return parsed
|
||
|
||
|
||
def _positive_float(value: str) -> float:
|
||
parsed = float(value)
|
||
if not math.isfinite(parsed) or parsed <= 0:
|
||
raise argparse.ArgumentTypeError(f"value must be a finite number > 0, got {value!r}")
|
||
return parsed
|
||
|
||
|
||
def _percent_position(value: str) -> float:
|
||
parsed = float(value)
|
||
if not math.isfinite(parsed) or parsed < 0 or parsed > 100:
|
||
raise argparse.ArgumentTypeError(
|
||
f"custom-position must be a finite number between 0 and 100, got {value!r}"
|
||
)
|
||
return parsed
|
||
|
||
|
||
def _hex_color(value: str) -> str:
|
||
if not re.fullmatch(r"#[0-9a-fA-F]{6}", value):
|
||
raise argparse.ArgumentTypeError(
|
||
f"color must use #RRGGBB format, got {value!r}"
|
||
)
|
||
return value
|
||
|
||
|
||
def _task_id(value: str) -> str:
|
||
"""CLI 自定义任务标识只接受 UUID,避免该值被解释为文件系统路径。"""
|
||
try:
|
||
return str(UUID(value.strip()))
|
||
except (AttributeError, ValueError) as exc:
|
||
raise argparse.ArgumentTypeError(
|
||
f"task-id must be a valid UUID, got {value!r}"
|
||
) from exc
|
||
|
||
|
||
_TRANSITION_MODE_VALUES = {
|
||
"none": None,
|
||
"shuffle": "Shuffle",
|
||
"fade-in": "FadeIn",
|
||
"fade-out": "FadeOut",
|
||
"slide-in": "SlideIn",
|
||
"slide-out": "SlideOut",
|
||
}
|
||
|
||
|
||
def _transition_mode(value: str) -> str | None:
|
||
normalized = value.strip().lower()
|
||
if normalized not in _TRANSITION_MODE_VALUES:
|
||
allowed = ", ".join(_TRANSITION_MODE_VALUES)
|
||
raise argparse.ArgumentTypeError(
|
||
f"video-transition-mode must be one of: {allowed}"
|
||
)
|
||
return _TRANSITION_MODE_VALUES[normalized]
|
||
|
||
|
||
def _bgm_type(value: str) -> str:
|
||
normalized = value.strip().lower()
|
||
if normalized == "none":
|
||
return ""
|
||
if normalized in {"", "random", "custom"}:
|
||
return normalized
|
||
raise argparse.ArgumentTypeError("bgm-type must be one of: none, random, custom")
|
||
|
||
|
||
def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
|
||
parser = argparse.ArgumentParser(
|
||
description=(
|
||
"Generate MoneyPrinterTurbo videos without the WebUI.\n\n"
|
||
"Provider settings and credentials are read from config.toml.\n"
|
||
"Default full-video generation requires a configured LLM and Pexels API key.\n"
|
||
"The default Edge TTS voice requires no API key."
|
||
),
|
||
epilog="""
|
||
Examples:
|
||
Generate a complete video with the default Edge TTS voice:
|
||
uv run python cli.py --video-subject "How AI is changing everyday life"
|
||
|
||
Generate from local files. Relative paths use the current working directory;
|
||
absolute paths are also accepted:
|
||
uv run python cli.py --video-subject "How AI is changing everyday life" \\
|
||
--video-source local --video-materials "./1.mp4,./2.mp4"
|
||
|
||
Generate with a prepared script and no voiceover:
|
||
uv run python cli.py --video-script "Your complete script" \\
|
||
--voice-name no-voice --stop-at video
|
||
|
||
Stop after script generation:
|
||
uv run python cli.py --video-subject "How AI is changing everyday life" --stop-at script
|
||
|
||
Pipeline stages:
|
||
script Generate or return the script.
|
||
terms Generate material search terms; unavailable with local materials.
|
||
audio Generate TTS, silent audio, or use --custom-audio-file.
|
||
subtitle Generate subtitles when enabled.
|
||
materials Download online materials or preprocess local files.
|
||
video Generate the final video and run configured cross-posting.
|
||
The command stops immediately after the selected stage and prints that stage's result.
|
||
|
||
Output and exit status:
|
||
Task files are written to storage/tasks/<task-id>/. A successful command prints one
|
||
JSON object to stdout and exits with 0. Task failures exit with 1; argument errors
|
||
exit with 2. Runtime logs are written to stderr.
|
||
""",
|
||
formatter_class=_CliHelpFormatter,
|
||
)
|
||
|
||
content_group = parser.add_argument_group("script and content")
|
||
content_group.add_argument(
|
||
"--video-subject",
|
||
default="",
|
||
help="video topic; required unless --video-script is provided",
|
||
)
|
||
content_group.add_argument(
|
||
"--video-script",
|
||
default="",
|
||
help="complete script; skips LLM script generation when provided",
|
||
)
|
||
content_group.add_argument(
|
||
"--video-terms",
|
||
default=None,
|
||
help="comma-separated material search terms; generated automatically when omitted",
|
||
)
|
||
content_group.add_argument(
|
||
"--video-language",
|
||
default=None,
|
||
help=(
|
||
"script language code, such as zh-CN or en-US (default: auto-detect)"
|
||
),
|
||
)
|
||
content_group.add_argument(
|
||
"--paragraph-number",
|
||
type=_paragraph_count,
|
||
default=None,
|
||
help="number of generated script paragraphs, from 1 to 10 (default: 1)",
|
||
)
|
||
content_group.add_argument(
|
||
"--video-script-prompt",
|
||
default=None,
|
||
help="additional requirements for LLM script generation",
|
||
)
|
||
content_group.add_argument(
|
||
"--custom-system-prompt",
|
||
default=None,
|
||
help="replace the default LLM system prompt for script generation",
|
||
)
|
||
|
||
material_group = parser.add_argument_group("materials and pipeline")
|
||
material_group.add_argument(
|
||
"--video-source",
|
||
default="pexels",
|
||
choices=["pexels", "pixabay", "coverr", "local"],
|
||
help="video material provider; online providers require matching API keys in config.toml",
|
||
)
|
||
material_group.add_argument(
|
||
"--video-materials",
|
||
default="",
|
||
metavar="PATH[,PATH...]",
|
||
help=(
|
||
"comma-separated local image/video paths for --video-source local; relative "
|
||
"paths use the current working directory, then storage/local_videos as a "
|
||
"compatibility fallback; absolute paths are accepted"
|
||
),
|
||
)
|
||
material_group.add_argument(
|
||
"--stop-at",
|
||
default="video",
|
||
choices=_PIPELINE_STAGES,
|
||
help="stop after this pipeline stage; see the stage order below",
|
||
)
|
||
|
||
video_group = parser.add_argument_group("video output")
|
||
video_group.add_argument(
|
||
"--video-count",
|
||
type=_positive_int,
|
||
default=1,
|
||
help="number of output videos, at least 1",
|
||
)
|
||
video_group.add_argument(
|
||
"--video-aspect",
|
||
choices=["9:16", "16:9", "1:1"],
|
||
default="9:16",
|
||
help="output aspect ratio: portrait, landscape, or square",
|
||
)
|
||
video_group.add_argument(
|
||
"--video-concat-mode",
|
||
choices=["random", "sequential"],
|
||
default=None,
|
||
help="source clip concatenation order (default: random)",
|
||
)
|
||
video_group.add_argument(
|
||
"--video-transition-mode",
|
||
type=_transition_mode,
|
||
default=None,
|
||
metavar="{none,shuffle,fade-in,fade-out,slide-in,slide-out}",
|
||
help="transition applied between source clips (default: none)",
|
||
)
|
||
video_group.add_argument(
|
||
"--video-clip-duration",
|
||
type=_positive_int,
|
||
default=None,
|
||
help=(
|
||
"maximum duration of each source clip in seconds, at least 1 (default: 5)"
|
||
),
|
||
)
|
||
video_group.add_argument(
|
||
"--match-materials-to-script",
|
||
default=None,
|
||
action=argparse.BooleanOptionalAction,
|
||
help=(
|
||
"preserve script keyword order while selecting and concatenating materials "
|
||
"(default: disabled)"
|
||
),
|
||
)
|
||
video_group.add_argument(
|
||
"--n-threads",
|
||
type=_positive_int,
|
||
default=None,
|
||
help="FFmpeg worker thread count, at least 1 (default: 2)",
|
||
)
|
||
|
||
audio_group = parser.add_argument_group("voiceover and background music")
|
||
audio_group.add_argument(
|
||
"--voice-name",
|
||
default=DEFAULT_VOICE_NAME,
|
||
help=(
|
||
"TTS voice identifier; use 'no-voice' for silent output. Provider-specific "
|
||
"identifiers use prefixes such as gemini:, mimo:, elevenlabs:, and chatterbox:"
|
||
),
|
||
)
|
||
audio_group.add_argument(
|
||
"--voice-volume",
|
||
type=_non_negative_float,
|
||
default=None,
|
||
help=(
|
||
"final voiceover volume multiplier, a finite number >= 0 (default: 1.0)"
|
||
),
|
||
)
|
||
audio_group.add_argument(
|
||
"--voice-rate",
|
||
type=_positive_float,
|
||
default=None,
|
||
help=(
|
||
"speech rate multiplier, a finite number > 0 (default: 1.0)"
|
||
),
|
||
)
|
||
audio_group.add_argument(
|
||
"--custom-audio-file",
|
||
default=None,
|
||
metavar="PATH",
|
||
help=(
|
||
"existing MP3/WAV/M4A/AAC/FLAC/OGG voiceover; relative paths use the "
|
||
"current working directory. This skips TTS; set subtitle_provider=whisper "
|
||
"to transcribe it"
|
||
),
|
||
)
|
||
audio_group.add_argument(
|
||
"--bgm-type",
|
||
type=_bgm_type,
|
||
default=None,
|
||
metavar="{none,random,custom}",
|
||
help=(
|
||
"background music mode; --bgm-file implies custom when omitted "
|
||
"(default: random)"
|
||
),
|
||
)
|
||
audio_group.add_argument(
|
||
"--bgm-file",
|
||
default=None,
|
||
metavar="PATH",
|
||
help=(
|
||
"custom MP3 inside resource/songs; accepts a filename or a path "
|
||
"relative to the project root"
|
||
),
|
||
)
|
||
audio_group.add_argument(
|
||
"--bgm-volume",
|
||
type=_non_negative_float,
|
||
default=None,
|
||
help=(
|
||
"background music volume multiplier, a finite number >= 0 (default: 0.2)"
|
||
),
|
||
)
|
||
|
||
subtitle_group = parser.add_argument_group("subtitles")
|
||
subtitle_group.add_argument(
|
||
"--subtitle-enabled",
|
||
default=True,
|
||
action=argparse.BooleanOptionalAction,
|
||
help=(
|
||
"enable subtitles; use --no-subtitle-enabled to disable "
|
||
"(default: enabled)"
|
||
),
|
||
)
|
||
subtitle_group.add_argument(
|
||
"--font-name",
|
||
default=None,
|
||
help=(
|
||
"subtitle font filename inside resource/fonts "
|
||
"(default: STHeitiMedium.ttc)"
|
||
),
|
||
)
|
||
subtitle_group.add_argument(
|
||
"--subtitle-position",
|
||
choices=["top", "center", "bottom", "custom"],
|
||
default=None,
|
||
help=(
|
||
"subtitle vertical position (default: [ui].subtitle_position from "
|
||
"config.toml; bottom when unset)"
|
||
),
|
||
)
|
||
subtitle_group.add_argument(
|
||
"--custom-position",
|
||
type=_percent_position,
|
||
default=None,
|
||
help=(
|
||
"custom position as percent from top, 0-100; requires "
|
||
"--subtitle-position custom (default: [ui].custom_position from "
|
||
"config.toml; 70 when unset)"
|
||
),
|
||
)
|
||
subtitle_group.add_argument(
|
||
"--text-fore-color",
|
||
type=_hex_color,
|
||
default=None,
|
||
help=(
|
||
"subtitle text color in #RRGGBB format; quote the value in shells "
|
||
"that treat # as a comment (default: #FFFFFF)"
|
||
),
|
||
)
|
||
subtitle_group.add_argument(
|
||
"--font-size",
|
||
type=_positive_int,
|
||
default=None,
|
||
help="subtitle font size (default: 60)",
|
||
)
|
||
subtitle_group.add_argument(
|
||
"--stroke-color",
|
||
type=_hex_color,
|
||
default=None,
|
||
help="subtitle outline color in #RRGGBB format (default: #000000)",
|
||
)
|
||
subtitle_group.add_argument(
|
||
"--stroke-width",
|
||
type=_non_negative_float,
|
||
default=None,
|
||
help=(
|
||
"subtitle outline width, a finite number >= 0 (default: 1.5)"
|
||
),
|
||
)
|
||
subtitle_group.add_argument(
|
||
"--subtitle-background-enabled",
|
||
default=None,
|
||
action=argparse.BooleanOptionalAction,
|
||
help=(
|
||
"enable subtitle background; use --no-subtitle-background-enabled to "
|
||
"disable (default: enabled)"
|
||
),
|
||
)
|
||
subtitle_group.add_argument(
|
||
"--subtitle-background-color",
|
||
type=_hex_color,
|
||
default=None,
|
||
help="subtitle background color in #RRGGBB format (default: #000000)",
|
||
)
|
||
subtitle_group.add_argument(
|
||
"--rounded-subtitle-background",
|
||
default=None,
|
||
action=argparse.BooleanOptionalAction,
|
||
help="use a rounded subtitle background (default: disabled)",
|
||
)
|
||
|
||
execution_group = parser.add_argument_group("execution")
|
||
execution_group.add_argument(
|
||
"--task-id",
|
||
type=_task_id,
|
||
default=None,
|
||
help="custom UUID used for storage/tasks/<task-id>; generated automatically when omitted",
|
||
)
|
||
args = parser.parse_args(argv)
|
||
|
||
if not args.video_subject.strip() and not args.video_script.strip():
|
||
parser.error("one of --video-subject or --video-script is required")
|
||
|
||
if args.video_source == "local" and args.stop_at == "terms":
|
||
parser.error(
|
||
"--stop-at terms has no effect with --video-source local "
|
||
"(search terms are not generated for local sources)"
|
||
)
|
||
|
||
stage_requires_materials = args.stop_at in {"materials", "video"}
|
||
has_video_materials = bool((args.video_materials or "").strip())
|
||
if args.video_source == "local" and stage_requires_materials and not has_video_materials:
|
||
parser.error(
|
||
"--video-materials is required with --video-source local when "
|
||
"--stop-at is materials or video"
|
||
)
|
||
if args.video_source != "local" and has_video_materials:
|
||
parser.error("--video-materials can only be used with --video-source local")
|
||
|
||
if args.bgm_file:
|
||
if args.bgm_type in (None, "custom"):
|
||
args.bgm_type = "custom"
|
||
else:
|
||
parser.error("--bgm-file cannot be combined with --bgm-type none or random")
|
||
elif args.bgm_type == "custom":
|
||
parser.error("--bgm-file is required when --bgm-type is custom")
|
||
|
||
if args.custom_position is not None and args.subtitle_position != "custom":
|
||
parser.error("--custom-position requires --subtitle-position custom")
|
||
if args.stop_at == "subtitle" and not args.subtitle_enabled:
|
||
parser.error("--stop-at subtitle cannot be combined with --no-subtitle-enabled")
|
||
if args.subtitle_background_enabled is False and (
|
||
args.subtitle_background_color is not None
|
||
or args.rounded_subtitle_background is True
|
||
):
|
||
parser.error(
|
||
"subtitle background color or rounding cannot be enabled together with "
|
||
"--no-subtitle-background-enabled"
|
||
)
|
||
|
||
return args
|
||
|
||
|
||
def build_video_params(args: argparse.Namespace) -> VideoParams:
|
||
# 参数帮助和校验不需要加载应用配置。仅在真正构建任务参数时导入模型,
|
||
# 避免执行 ``cli.py -h`` 时产生配置初始化日志。
|
||
from app.models.schema import MaterialInfo, VideoParams
|
||
|
||
video_terms = args.video_terms
|
||
if video_terms:
|
||
video_terms = [
|
||
term.strip() for term in re.split(r"[,,]", video_terms) if term.strip()
|
||
]
|
||
|
||
video_materials = None
|
||
materials_arg = args.video_materials or ""
|
||
if materials_arg.strip():
|
||
video_materials = [
|
||
# Actual duration will be detected during video processing; use 0 as placeholder.
|
||
MaterialInfo(provider="local", url=item.strip(), duration=0)
|
||
for item in materials_arg.split(",")
|
||
if item.strip()
|
||
]
|
||
|
||
params_kwargs = {
|
||
"video_subject": args.video_subject.strip(),
|
||
"video_script": args.video_script,
|
||
"video_terms": video_terms,
|
||
"video_source": args.video_source,
|
||
"video_materials": video_materials,
|
||
"video_count": args.video_count,
|
||
"video_aspect": args.video_aspect,
|
||
"voice_name": args.voice_name,
|
||
"subtitle_enabled": args.subtitle_enabled,
|
||
}
|
||
|
||
optional_arg_names = [
|
||
"video_language",
|
||
"paragraph_number",
|
||
"video_script_prompt",
|
||
"custom_system_prompt",
|
||
"video_concat_mode",
|
||
"video_transition_mode",
|
||
"video_clip_duration",
|
||
"match_materials_to_script",
|
||
"n_threads",
|
||
"voice_volume",
|
||
"voice_rate",
|
||
"custom_audio_file",
|
||
"bgm_type",
|
||
"bgm_file",
|
||
"bgm_volume",
|
||
"font_name",
|
||
"subtitle_position",
|
||
"custom_position",
|
||
"text_fore_color",
|
||
"font_size",
|
||
"stroke_color",
|
||
"stroke_width",
|
||
"rounded_subtitle_background",
|
||
]
|
||
for name in optional_arg_names:
|
||
value = getattr(args, name)
|
||
if value is not None:
|
||
params_kwargs[name] = value
|
||
|
||
if args.subtitle_background_enabled is False:
|
||
params_kwargs["text_background_color"] = False
|
||
params_kwargs["rounded_subtitle_background"] = False
|
||
elif args.subtitle_background_color is not None:
|
||
params_kwargs["text_background_color"] = args.subtitle_background_color
|
||
elif args.subtitle_background_enabled is True:
|
||
params_kwargs["text_background_color"] = True
|
||
|
||
return VideoParams(**params_kwargs)
|
||
|
||
|
||
def _resolve_cli_file(
|
||
raw_path: str,
|
||
*,
|
||
description: str,
|
||
fallback_dir: str | None = None,
|
||
) -> str:
|
||
"""
|
||
将 CLI 文件参数按当前工作目录解析为绝对路径,
|
||
并在任务开始前确认存在。
|
||
|
||
本地素材旧版本始终相对 ``storage/local_videos`` 解析。为兼容已有脚本,
|
||
当前目录找不到相对路径时允许回退该目录;绝对路径始终按用户输入
|
||
直接解析。
|
||
"""
|
||
expanded_path = os.path.expanduser(raw_path.strip())
|
||
if not expanded_path:
|
||
raise ValueError(f"{description} path cannot be empty")
|
||
|
||
candidate = (
|
||
expanded_path
|
||
if os.path.isabs(expanded_path)
|
||
else os.path.join(os.getcwd(), expanded_path)
|
||
)
|
||
resolved_path = os.path.realpath(candidate)
|
||
if not os.path.isfile(resolved_path) and fallback_dir and not os.path.isabs(expanded_path):
|
||
resolved_path = os.path.realpath(os.path.join(fallback_dir, expanded_path))
|
||
|
||
if not os.path.isfile(resolved_path):
|
||
raise ValueError(f"{description} file does not exist: {raw_path}")
|
||
return resolved_path
|
||
|
||
|
||
def _path_is_within_directory(file_path: str, directory: str) -> bool:
|
||
try:
|
||
return os.path.commonpath(
|
||
[os.path.realpath(directory), os.path.realpath(file_path)]
|
||
) == os.path.realpath(directory)
|
||
except ValueError:
|
||
# Windows 不同盘符无法计算 commonpath,此时文件显然不在目标目录内。
|
||
return False
|
||
|
||
|
||
def _resolve_managed_resource_file(
|
||
raw_path: str,
|
||
*,
|
||
resource_dir: str,
|
||
description: str,
|
||
) -> str:
|
||
"""解析项目资源文件,并确保绝对路径仍位于对应资源目录内。"""
|
||
from app.utils import utils
|
||
|
||
expanded_path = os.path.expanduser(raw_path.strip())
|
||
candidates = (
|
||
[expanded_path]
|
||
if os.path.isabs(expanded_path)
|
||
else [
|
||
os.path.join(resource_dir, expanded_path),
|
||
os.path.join(utils.root_dir(), expanded_path),
|
||
]
|
||
)
|
||
for candidate in candidates:
|
||
resolved_path = os.path.realpath(candidate)
|
||
if os.path.isfile(resolved_path) and _path_is_within_directory(
|
||
resolved_path, resource_dir
|
||
):
|
||
return resolved_path
|
||
raise ValueError(
|
||
f"{description} file must exist inside {resource_dir}: {raw_path}"
|
||
)
|
||
|
||
|
||
def prepare_cli_files(params: VideoParams, stop_at: str) -> None:
|
||
"""
|
||
在调用 LLM/TTS 前准备 CLI 文件,避免长流程运行到后期才报告路径错误。
|
||
|
||
服务层为了保护 API 请求,只允许读取 ``storage/local_videos`` 内的素材。
|
||
CLI 是本地入口,接受当前目录相对路径和绝对路径。目录外素材会
|
||
复制到受控目录,再把参数替换为服务层可安全使用的绝对路径。
|
||
"""
|
||
from app.models import const
|
||
from app.utils import utils
|
||
|
||
local_material_extensions = {
|
||
*(f".{extension}" for extension in const.FILE_TYPE_VIDEOS),
|
||
*(f".{extension}" for extension in const.FILE_TYPE_IMAGES),
|
||
".avi",
|
||
".flv",
|
||
}
|
||
|
||
if params.custom_audio_file:
|
||
params.custom_audio_file = _resolve_cli_file(
|
||
params.custom_audio_file,
|
||
description="custom audio",
|
||
)
|
||
audio_extension = os.path.splitext(params.custom_audio_file)[1].lower()
|
||
if audio_extension not in _CUSTOM_AUDIO_EXTENSIONS:
|
||
allowed = ", ".join(sorted(_CUSTOM_AUDIO_EXTENSIONS))
|
||
raise ValueError(
|
||
f"unsupported custom audio type {audio_extension or '<none>'}; "
|
||
f"allowed extensions: {allowed}"
|
||
)
|
||
|
||
if params.bgm_type == "custom" and params.bgm_file:
|
||
params.bgm_file = _resolve_managed_resource_file(
|
||
params.bgm_file,
|
||
resource_dir=utils.song_dir(),
|
||
description="background music",
|
||
)
|
||
if not params.bgm_file.lower().endswith(".mp3"):
|
||
raise ValueError("background music must use the .mp3 extension")
|
||
|
||
if params.subtitle_enabled and params.font_name and stop_at == "video":
|
||
font_path = _resolve_managed_resource_file(
|
||
params.font_name,
|
||
resource_dir=utils.font_dir(),
|
||
description="subtitle font",
|
||
)
|
||
if not font_path.lower().endswith((".ttf", ".ttc")):
|
||
raise ValueError("subtitle font must use the .ttf or .ttc extension")
|
||
# 下游根据 resource/fonts 内的文件名拼接路径,因此仍保留纯文件名。
|
||
params.font_name = os.path.basename(font_path)
|
||
|
||
if params.video_source != "local" or stop_at not in {"materials", "video"}:
|
||
return
|
||
|
||
local_videos_dir = utils.storage_dir("local_videos", create=True)
|
||
resolved_materials: list[tuple[MaterialInfo, str, str]] = []
|
||
for material in params.video_materials or []:
|
||
source_path = _resolve_cli_file(
|
||
material.url,
|
||
description="local material",
|
||
fallback_dir=local_videos_dir,
|
||
)
|
||
extension = os.path.splitext(source_path)[1].lower()
|
||
if extension not in local_material_extensions:
|
||
allowed = ", ".join(sorted(local_material_extensions))
|
||
raise ValueError(
|
||
f"unsupported local material type {extension or '<none>'}: "
|
||
f"{material.url}; allowed extensions: {allowed}"
|
||
)
|
||
resolved_materials.append((material, source_path, extension))
|
||
|
||
# 所有输入检查通过后再复制,避免第二个文件无效时留下第一个文件的
|
||
# 孤儿副本。
|
||
prepared_paths: dict[str, str] = {}
|
||
for material, source_path, extension in resolved_materials:
|
||
prepared_path = prepared_paths.get(source_path)
|
||
if prepared_path is None:
|
||
if _path_is_within_directory(source_path, local_videos_dir):
|
||
prepared_path = source_path
|
||
else:
|
||
prepared_path = os.path.join(
|
||
local_videos_dir,
|
||
f"cli-material-{uuid4().hex}{extension}",
|
||
)
|
||
shutil.copy2(source_path, prepared_path)
|
||
logger.info(
|
||
"copied CLI local material into managed storage: "
|
||
f"source={source_path}, target={prepared_path}"
|
||
)
|
||
prepared_paths[source_path] = prepared_path
|
||
|
||
material.url = prepared_path
|
||
|
||
|
||
def run_cli(argv: Sequence[str] | None = None) -> int:
|
||
args = parse_args(argv)
|
||
try:
|
||
params = build_video_params(args)
|
||
prepare_cli_files(params, stop_at=args.stop_at)
|
||
except (ValueError, OSError) as exc:
|
||
logger.error(f"invalid CLI input: {exc}")
|
||
return 2
|
||
|
||
# 帮助参数会在 parse_args 中直接退出。把业务服务延迟到这里导入,
|
||
# 保证 -h/--help 输出干净,同时不改变实际任务的初始化流程。
|
||
from app.services import task as tm
|
||
from app.utils import utils
|
||
|
||
task_id = args.task_id or utils.get_uuid()
|
||
logger.info(f"start CLI task: task_id={task_id}, stop_at={args.stop_at}")
|
||
try:
|
||
result = tm.start(task_id=task_id, params=params, stop_at=args.stop_at)
|
||
except Exception as exc:
|
||
logger.exception(
|
||
f"CLI task failed with an unexpected error: task_id={task_id}, error={exc}"
|
||
)
|
||
return 1
|
||
if not result:
|
||
logger.error(f"CLI task failed: task_id={task_id}, stop_at={args.stop_at}")
|
||
return 1
|
||
|
||
print(json.dumps({"task_id": task_id, "result": result}, ensure_ascii=False))
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
raise SystemExit(run_cli())
|