Spaces:
Sleeping
Sleeping
Download cli.py from kodnuke/youtube-bot: direct link, hf CLI and curl.
- Browser
- Download file 67.3 kB
-
https://huggingface.co/spaces/kodnuke/youtube-bot/resolve/main/cli.py
- Command line
-
hf download hf://spaces/kodnuke/youtube-bot/cli.py
-
curl -L -o cli.py https://huggingface.co/spaces/kodnuke/youtube-bot/resolve/main/cli.py
67.3 kB
| from __future__ import annotations | |
| import argparse | |
| import copy | |
| import json | |
| import math | |
| import os | |
| import re | |
| import shutil | |
| import sys | |
| from typing import TYPE_CHECKING, Any, Sequence | |
| from uuid import UUID, uuid4 | |
| from loguru import logger | |
| if TYPE_CHECKING: | |
| from app.models.schema import MaterialInfo, VideoParams | |
| DEFAULT_VOICE_NAME = "zh-CN-XiaoxiaoNeural-Female" | |
| # 对应 webui/Main.py 的 VOICE_MODE_NONE 和 VOICE_MODE_UPLOAD。两端目前没有 | |
| # 共享这些常量,因此在这里保留字面值并注明来源。 | |
| UI_VOICE_MODE_NONE = "none" | |
| # 字幕位置的内置默认值。VideoParams 也有同名默认值,但它是 Pydantic 字段 | |
| # 默认,只在模块导入时读取一次 config.ui:此时 config.toml 里的非法值会被 | |
| # 直接冻结进模型,既不会校验也无法在测试中替换。CLI 因此自己保存一份, | |
| # 保证“非法保存值回退到默认值”对命令行始终成立。 | |
| DEFAULT_SUBTITLE_POSITION = "bottom" | |
| DEFAULT_CUSTOM_POSITION = 70.0 | |
| UI_VOICE_MODE_UPLOAD = "upload" | |
| # 这两种保存的配音方式都表示不要自动配音。 | |
| UI_VOICE_MODES_WITHOUT_TTS = frozenset({UI_VOICE_MODE_NONE, UI_VOICE_MODE_UPLOAD}) | |
| _PIPELINE_STAGES = ("script", "terms", "audio", "subtitle", "materials", "video") | |
| _CUSTOM_AUDIO_EXTENSIONS = {".mp3", ".wav", ".m4a", ".aac", ".flac", ".ogg"} | |
| _BATCH_FILE_MAX_BYTES = 1024 * 1024 | |
| _BATCH_TASK_MAX_COUNT = 100 | |
| # 单任务 argparse 和批量清单必须共享同一来源集合。此前两处手工维护导致 | |
| # openai_image 只在单任务入口可用;集中定义后,新增 Provider 不会再次遗漏 | |
| # 批量校验。这里仅包含 CLI 已公开支持的来源,不强行暴露 WebUI 专属流程。 | |
| _CLI_VIDEO_SOURCES = ( | |
| "pexels", | |
| "pixabay", | |
| "coverr", | |
| "wavespeed", | |
| "volcengine_seedance", | |
| "ofox", | |
| "metaso_minimax", | |
| "muapi", | |
| "openai_image", | |
| "local", | |
| ) | |
| 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 | |
| # 片段播放速度的取值范围。app/utils/utils.py 的 normalize_clip_speed() 用同一 | |
| # 范围做限幅,webui/Main.py 的滑块也使用 0.5~2.0。集中定义后,单任务参数、 | |
| # 批量清单校验和 [ui] 保存值不会再次出现取值不一致。 | |
| _CLIP_SPEED_MIN = 0.5 | |
| _CLIP_SPEED_MAX = 2.0 | |
| def _clip_speed(value: str) -> float: | |
| parsed = float(value) | |
| if not math.isfinite(parsed) or not _CLIP_SPEED_MIN <= parsed <= _CLIP_SPEED_MAX: | |
| raise argparse.ArgumentTypeError( | |
| "video-clip-speed must be a finite number between " | |
| f"{_CLIP_SPEED_MIN} and {_CLIP_SPEED_MAX}, 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 _subtitle_position(value: str) -> str: | |
| """校验保存的字幕位置,取值范围与命令行参数保持一致。""" | |
| if value not in _SUBTITLE_POSITION_VALUES: | |
| raise argparse.ArgumentTypeError( | |
| "subtitle-position must be one of: " | |
| f"{', '.join(_SUBTITLE_POSITION_VALUES)}, got {value!r}" | |
| ) | |
| return value | |
| def _subtitle_display_mode(value: str) -> str: | |
| """校验保存的字幕展示模式,取值范围与命令行参数保持一致。""" | |
| if value not in _SUBTITLE_DISPLAY_MODE_VALUES: | |
| raise argparse.ArgumentTypeError( | |
| "subtitle-display-mode must be one of: " | |
| f"{', '.join(_SUBTITLE_DISPLAY_MODE_VALUES)}, got {value!r}" | |
| ) | |
| return value | |
| def _subtitle_animation(value: str) -> str: | |
| """校验保存的字幕动画,取值范围与命令行参数保持一致。""" | |
| if value not in _SUBTITLE_ANIMATION_VALUES: | |
| raise argparse.ArgumentTypeError( | |
| "subtitle-animation must be one of: " | |
| f"{', '.join(_SUBTITLE_ANIMATION_VALUES)}, 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", | |
| "zoom-in": "ZoomIn", | |
| "zoom-out": "ZoomOut", | |
| } | |
| # 单任务 argparse 和批量清单必须共享同一份字幕位置取值,避免再次出现 | |
| # 「WebUI 能保存、CLI 却拒绝」的落差。取值需与 app/services/video.py 的 | |
| # 渲染分支及 webui/Main.py 的下拉框保持一致。 | |
| _SUBTITLE_POSITION_VALUES = ( | |
| "top", | |
| "center", | |
| "bottom", | |
| "two_thirds_bottom", | |
| "custom", | |
| ) | |
| # 字幕展示模式与入场动画由 WebUI 保存进 [ui]。两者随「逐词字幕 + 弹跳动画」 | |
| # 加入时只改了 WebUI 与模型字段默认值,命令行一直缺少开关。取值需与 | |
| # app/models/schema.py 的 _SUBTITLE_DISPLAY_MODES / _SUBTITLE_ANIMATIONS 一致。 | |
| _SUBTITLE_DISPLAY_MODE_VALUES = ("sentence", "word_by_word") | |
| _SUBTITLE_ANIMATION_VALUES = ("none", "pop_spring") | |
| 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 _video_fit_mode(value: str) -> str: | |
| normalized = value.strip().lower() | |
| if normalized not in {"cover", "contain"}: | |
| raise argparse.ArgumentTypeError( | |
| "video-fit-mode must be one of: cover, contain" | |
| ) | |
| return normalized | |
| # 只有这些取值会请求外部音乐生成服务,因此只有它们消费配乐提示词。取值需与 | |
| # app/services/task.py 的 _VIDEO_MUSIC_PROVIDERS 保持一致;集中定义后,新增 | |
| # 配乐供应商不会再次遗漏 --bgm-type 校验、批量校验和提示词参数。 | |
| _CLI_MUSIC_BGM_TYPES = ("sonilo", "elevenlabs") | |
| _BGM_TYPE_CHOICES_TEXT = "none, random, custom, " + ", ".join(_CLI_MUSIC_BGM_TYPES) | |
| _BGM_TYPE_METAVAR = "{none,random,custom," + ",".join(_CLI_MUSIC_BGM_TYPES) + "}" | |
| def _bgm_type(value: str) -> str: | |
| normalized = value.strip().lower() | |
| if normalized == "none": | |
| return "" | |
| if normalized in {"", "random", "custom", *_CLI_MUSIC_BGM_TYPES}: | |
| return normalized | |
| raise argparse.ArgumentTypeError( | |
| f"bgm-type must be one of: {_BGM_TYPE_CHOICES_TEXT}" | |
| ) | |
| 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 | |
| Run a JSON array or JSONL manifest. CLI options provide defaults and each object | |
| overrides VideoParams fields for one task: | |
| uv run python cli.py --batch-file ./tasks.jsonl --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. Batch mode prints one JSON summary after all | |
| tasks finish. Task failures exit with 1; argument or manifest errors exit with 2 | |
| before any batch task starts. Runtime logs are written to stderr. | |
| Batch manifests: | |
| --batch-file accepts a UTF-8 JSON array or JSONL file with one object per non-empty | |
| line (up to 100 tasks and 1 MiB). Objects may override VideoParams fields; unknown | |
| fields are rejected. Every merged task needs video_subject or video_script. | |
| The manifest path is relative to the current working directory. Relative | |
| custom_audio_file and local video_materials[].url values declared in a manifest are | |
| relative to the manifest directory. Relative paths supplied by CLI options remain | |
| relative to the current working directory. bgm_file and font_name keep their managed | |
| storage/resource lookup rules. --stop-at is a batch-wide CLI option. The summary has | |
| total, succeeded, failed, and tasks keys; every task entry has index, task_id, | |
| status, result, failed_stage, and error. | |
| """, | |
| 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=_CLI_VIDEO_SOURCES, | |
| 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", | |
| ) | |
| material_group.add_argument( | |
| "--confirm-wavespeed-charge", | |
| action="store_true", | |
| help=( | |
| "confirm that WaveSpeed video generation creates paid tasks; required " | |
| "with --video-source wavespeed for materials or video output" | |
| ), | |
| ) | |
| material_group.add_argument( | |
| "--confirm-seedance-charge", | |
| action="store_true", | |
| help=( | |
| "confirm that Volcano Engine Seedance creates paid Ark tasks; required " | |
| "with --video-source volcengine_seedance for materials or video output" | |
| ), | |
| ) | |
| material_group.add_argument( | |
| "--confirm-ofox-charge", | |
| action="store_true", | |
| help=( | |
| "confirm that OFox video generation creates paid tasks; required " | |
| "with --video-source ofox for materials or video output" | |
| ), | |
| ) | |
| material_group.add_argument( | |
| "--confirm-metaso-minimax-charge", | |
| action="store_true", | |
| help=( | |
| "confirm that Metaso MiniMax H3 creates paid video tasks; required " | |
| "with --video-source metaso_minimax for materials or video output" | |
| ), | |
| ) | |
| material_group.add_argument( | |
| "--confirm-muapi-charge", | |
| action="store_true", | |
| help=( | |
| "confirm that MuAPI video generation creates paid tasks; required " | |
| "with --video-source muapi for materials or video output" | |
| ), | |
| ) | |
| 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-fit-mode", | |
| type=_video_fit_mode, | |
| choices=["cover", "contain"], | |
| default=None, | |
| help=( | |
| "fit mismatched source clips by filling and center-cropping (cover) " | |
| "or preserving the full frame with black bars (contain); default: cover" | |
| ), | |
| ) | |
| 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,zoom-in,zoom-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( | |
| "--video-clip-speed", | |
| type=_clip_speed, | |
| default=None, | |
| help=( | |
| "playback speed multiplier applied to every source clip, between " | |
| "0.5 and 2.0 (default: 1.0)" | |
| ), | |
| ) | |
| 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=None, | |
| help=( | |
| f"TTS voice identifier. Defaults to [ui].voice_name from " | |
| f"config.toml, otherwise {DEFAULT_VOICE_NAME}. A saved " | |
| "[ui].voice_mode of 'none' or 'upload' resolves to no-voice " | |
| "instead, unless this option is given. " | |
| "Use 'no-voice' for silent output. Provider-specific identifiers " | |
| "use prefixes such as gemini:, mimo:, elevenlabs:, chatterbox:, " | |
| "kokoro:, and voxcpm:" | |
| ), | |
| ) | |
| audio_group.add_argument( | |
| "--voice-volume", | |
| type=_non_negative_float, | |
| default=None, | |
| help=( | |
| "final voiceover volume multiplier, a finite number >= 0 (default: " | |
| "[ui].voice_volume from config.toml; 1.0 when unset)" | |
| ), | |
| ) | |
| audio_group.add_argument( | |
| "--voice-rate", | |
| type=_positive_float, | |
| default=None, | |
| help=( | |
| "speech rate multiplier, a finite number > 0 (default: " | |
| "[ui].voice_rate from config.toml; 1.0 when unset)" | |
| ), | |
| ) | |
| 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=_BGM_TYPE_METAVAR, | |
| help=( | |
| "background music mode; Sonilo reads its API key from config.toml or " | |
| "SONILO_API_KEY, ElevenLabs from config.toml or ELEVENLABS_API_KEY; " | |
| "--bgm-file implies custom when omitted (default: random)" | |
| ), | |
| ) | |
| audio_group.add_argument( | |
| "--sonilo-bgm-prompt", | |
| default=None, | |
| help="optional music style prompt for Sonilo, up to 2000 characters", | |
| ) | |
| audio_group.add_argument( | |
| "--video-music-prompt", | |
| default=None, | |
| help=( | |
| "optional music style prompt for the selected AI background music " | |
| "provider (sonilo or elevenlabs); the provider's own length limit " | |
| "is enforced before generation" | |
| ), | |
| ) | |
| audio_group.add_argument( | |
| "--bgm-file", | |
| default=None, | |
| metavar="PATH", | |
| help=( | |
| "custom supported audio file inside storage/bgm or resource/songs; " | |
| "accepts a filename or an allowed managed path" | |
| ), | |
| ) | |
| 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=None, | |
| action=argparse.BooleanOptionalAction, | |
| help=( | |
| "enable subtitles; use --no-subtitle-enabled to disable " | |
| "(default: [ui].subtitle_enabled from config.toml; enabled when " | |
| "unset)" | |
| ), | |
| ) | |
| subtitle_group.add_argument( | |
| "--font-name", | |
| default=None, | |
| help=( | |
| "subtitle font filename inside resource/fonts " | |
| "(default: [ui].font_name from config.toml; " | |
| "STHeitiMedium.ttc when unset)" | |
| ), | |
| ) | |
| subtitle_group.add_argument( | |
| "--subtitle-position", | |
| choices=_SUBTITLE_POSITION_VALUES, | |
| default=None, | |
| help=( | |
| "subtitle vertical position (default: [ui].subtitle_position from " | |
| "config.toml; bottom when unset)" | |
| ), | |
| ) | |
| subtitle_group.add_argument( | |
| "--subtitle-display-mode", | |
| choices=_SUBTITLE_DISPLAY_MODE_VALUES, | |
| default=None, | |
| help=( | |
| "subtitle timing: sentence by sentence, or one word at a time " | |
| "(default: [ui].subtitle_display_mode from config.toml; sentence)" | |
| ), | |
| ) | |
| subtitle_group.add_argument( | |
| "--subtitle-animation", | |
| choices=_SUBTITLE_ANIMATION_VALUES, | |
| default=None, | |
| help=( | |
| "subtitle entrance animation (default: [ui].subtitle_animation from " | |
| "config.toml; none 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: [ui].text_fore_color from " | |
| "config.toml; #FFFFFF when unset)" | |
| ), | |
| ) | |
| subtitle_group.add_argument( | |
| "--font-size", | |
| type=_positive_int, | |
| default=None, | |
| help=( | |
| "subtitle font size (default: [ui].font_size from config.toml; " | |
| "60 when unset)" | |
| ), | |
| ) | |
| subtitle_group.add_argument( | |
| "--stroke-color", | |
| type=_hex_color, | |
| default=None, | |
| help=( | |
| "subtitle outline color in #RRGGBB format (default: " | |
| "[ui].stroke_color from config.toml; #000000 when unset)" | |
| ), | |
| ) | |
| subtitle_group.add_argument( | |
| "--stroke-width", | |
| type=_non_negative_float, | |
| default=None, | |
| help=( | |
| "subtitle outline width, a finite number >= 0 (default: " | |
| "[ui].stroke_width from config.toml; 1.5 when unset)" | |
| ), | |
| ) | |
| subtitle_group.add_argument( | |
| "--subtitle-background-enabled", | |
| default=None, | |
| action=argparse.BooleanOptionalAction, | |
| help=( | |
| "enable subtitle background; use --no-subtitle-background-enabled to " | |
| "disable (default: [ui].subtitle_background_enabled from " | |
| "config.toml; disabled when unset)" | |
| ), | |
| ) | |
| subtitle_group.add_argument( | |
| "--subtitle-background-color", | |
| type=_hex_color, | |
| default=None, | |
| help=( | |
| "subtitle background color in #RRGGBB format (default: " | |
| "[ui].subtitle_background_color from config.toml)" | |
| ), | |
| ) | |
| subtitle_group.add_argument( | |
| "--rounded-subtitle-background", | |
| default=None, | |
| action=argparse.BooleanOptionalAction, | |
| help=( | |
| "use a rounded subtitle background (default: " | |
| "[ui].rounded_subtitle_background from config.toml; " | |
| "disabled when unset)" | |
| ), | |
| ) | |
| execution_group = parser.add_argument_group("execution") | |
| execution_mode = execution_group.add_mutually_exclusive_group() | |
| execution_mode.add_argument( | |
| "--task-id", | |
| type=_task_id, | |
| default=None, | |
| help="custom UUID used for storage/tasks/<task-id>; generated automatically when omitted", | |
| ) | |
| execution_mode.add_argument( | |
| "--batch-file", | |
| default=None, | |
| metavar="PATH", | |
| help=( | |
| "UTF-8 JSON array or JSONL task manifest; conflicts with --task-id and " | |
| "generates a UUID for every task" | |
| ), | |
| ) | |
| args = parser.parse_args(argv) | |
| if ( | |
| not args.batch_file | |
| and not args.video_subject.strip() | |
| and not args.video_script.strip() | |
| ): | |
| parser.error("one of --video-subject or --video-script is required") | |
| if not args.batch_file and 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 ( | |
| not args.batch_file | |
| and 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 not args.batch_file and args.video_source != "local" and has_video_materials: | |
| parser.error("--video-materials can only be used with --video-source local") | |
| if ( | |
| not args.batch_file | |
| and args.video_source == "wavespeed" | |
| and stage_requires_materials | |
| and not args.confirm_wavespeed_charge | |
| ): | |
| parser.error( | |
| "--confirm-wavespeed-charge is required with " | |
| "--video-source wavespeed" | |
| ) | |
| if ( | |
| not args.batch_file | |
| and args.video_source == "volcengine_seedance" | |
| and stage_requires_materials | |
| and not args.confirm_seedance_charge | |
| ): | |
| parser.error( | |
| "--confirm-seedance-charge is required with " | |
| "--video-source volcengine_seedance" | |
| ) | |
| if ( | |
| not args.batch_file | |
| and args.video_source == "ofox" | |
| and stage_requires_materials | |
| and not args.confirm_ofox_charge | |
| ): | |
| parser.error( | |
| "--confirm-ofox-charge is required with --video-source ofox" | |
| ) | |
| if ( | |
| not args.batch_file | |
| and args.video_source == "metaso_minimax" | |
| and stage_requires_materials | |
| and not args.confirm_metaso_minimax_charge | |
| ): | |
| parser.error( | |
| "--confirm-metaso-minimax-charge is required with " | |
| "--video-source metaso_minimax" | |
| ) | |
| if ( | |
| not args.batch_file | |
| and args.video_source == "muapi" | |
| and stage_requires_materials | |
| and not args.confirm_muapi_charge | |
| ): | |
| parser.error( | |
| "--confirm-muapi-charge is required with --video-source muapi" | |
| ) | |
| if args.bgm_file: | |
| if args.bgm_type in (None, "custom"): | |
| args.bgm_type = "custom" | |
| elif not args.batch_file: | |
| parser.error("--bgm-file can only be combined with --bgm-type custom") | |
| if args.sonilo_bgm_prompt: | |
| if args.bgm_type in (None, "sonilo"): | |
| args.bgm_type = "sonilo" | |
| elif not args.batch_file: | |
| parser.error( | |
| "--sonilo-bgm-prompt can only be combined with --bgm-type sonilo" | |
| ) | |
| # 提示词字段本身与供应商无关,所以不像 Sonilo 专用参数那样推断 bgm_type: | |
| # 推断出来的供应商可能不是用户想要的那个。必须显式选择 AI 配乐供应商, | |
| # 否则该提示词会被静默丢弃。批量清单仍可按任务单独设置该字段。 | |
| if ( | |
| not args.batch_file | |
| and args.video_music_prompt | |
| and args.bgm_type not in _CLI_MUSIC_BGM_TYPES | |
| ): | |
| parser.error( | |
| "--video-music-prompt requires --bgm-type sonilo or elevenlabs" | |
| ) | |
| if ( | |
| not args.batch_file | |
| and args.custom_position is not None | |
| and args.subtitle_position != "custom" | |
| ): | |
| parser.error("--custom-position requires --subtitle-position custom") | |
| # 只有显式的 --no-subtitle-enabled 才算冲突。默认值现在是 None, | |
| # 保存的关闭状态在 build_video_params 中处理,不应在这里报参数错误。 | |
| if ( | |
| not args.batch_file | |
| and args.stop_at == "subtitle" | |
| and args.subtitle_enabled is False | |
| ): | |
| parser.error("--stop-at subtitle cannot be combined with --no-subtitle-enabled") | |
| if not args.batch_file and 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 _ui_config_value(ui_config, key: str, expected_type, checker=None): | |
| """ | |
| 读取 ``[ui]`` 中保存的 WebUI 设置,取不到可用值时返回 ``None``。 | |
| 该配置段也可能被手工编辑,因此这里直接丢弃不可用的条目,让调用方回退到 | |
| 内置默认值,而不是把脏数据继续传下去,触发 traceback 或 ``VideoParams`` | |
| 的校验错误。 | |
| ``checker`` 复用命令行使用的同一批类型函数(如 ``_hex_color``),因此保存 | |
| 值和命令行参数遵循完全相同的取值规则,例如音量不能为负、颜色必须是 | |
| ``#RRGGBB``。 | |
| """ | |
| value = ui_config.get(key) | |
| if value is None: | |
| return None | |
| # ``isinstance(True, int)`` 为真,所以在期望数字时必须显式排除 bool。 | |
| if isinstance(value, bool) != (expected_type is bool): | |
| return None | |
| # TOML 中的 ``1`` 是整数,但对音量、语速这类字段同样是合法取值。 | |
| if expected_type is float and isinstance(value, int): | |
| value = float(value) | |
| if not isinstance(value, expected_type): | |
| return None | |
| if expected_type is str and not value.strip(): | |
| return None | |
| if checker is not None: | |
| try: | |
| return checker(str(value)) | |
| except argparse.ArgumentTypeError: | |
| return None | |
| return value | |
| def _resolve_subtitle_enabled(args: argparse.Namespace, ui_config) -> bool: | |
| """ | |
| 按优先级解析字幕开关:命令行 > WebUI 保存值 > 默认开启。 | |
| ``--stop-at subtitle`` 明确要求生成字幕,因此保存的关闭状态不能让该阶段 | |
| 变成空操作;显式的 --no-subtitle-enabled 与该阶段的组合在参数校验中已被 | |
| 拒绝,所以这里只需处理保存值。 | |
| """ | |
| if args.subtitle_enabled is not None: | |
| return args.subtitle_enabled | |
| if args.stop_at == "subtitle": | |
| return True | |
| saved = _ui_config_value(ui_config, "subtitle_enabled", bool) | |
| return True if saved is None else saved | |
| def _resolve_voice_name(args: argparse.Namespace, ui_config) -> str: | |
| """ | |
| 按优先级解析音色:命令行 > WebUI 保存的配音方式和音色 > 内置默认值。 | |
| WebUI 把“无配音”保存为独立的 voice_mode,同时保留用户上一次真正选择的 | |
| 音色,以便切回自动配音后恢复。所以这里不能只读 voice_name,否则保存的 | |
| 无配音状态会被忽略,并可能重新触发付费供应商请求。 | |
| """ | |
| from app.services.voice import NO_VOICE_NAME | |
| if args.voice_name: | |
| return args.voice_name | |
| # 无配音和上传自备音频都表示用户不想要自动配音。上传模式的文件路径不会 | |
| # 写入 [ui],CLI 无法复现该上传;此时沿用保存的音色会静默触发付费 TTS | |
| # 请求,因此两种模式都映射为 no-voice,需要配音时显式传 --voice-name。 | |
| if _ui_config_value(ui_config, "voice_mode", str) in UI_VOICE_MODES_WITHOUT_TTS: | |
| return NO_VOICE_NAME | |
| return _ui_config_value(ui_config, "voice_name", str) or DEFAULT_VOICE_NAME | |
| def build_video_params(args: argparse.Namespace) -> VideoParams: | |
| # 参数帮助和校验不需要加载应用配置。仅在真正构建任务参数时导入模型, | |
| # 避免执行 ``cli.py -h`` 时产生配置初始化日志。 | |
| from app.config import config | |
| from app.models.schema import MaterialInfo, VideoParams | |
| ui_config = config.ui | |
| 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": _resolve_voice_name(args, ui_config), | |
| "subtitle_enabled": _resolve_subtitle_enabled(args, ui_config), | |
| } | |
| optional_arg_names = [ | |
| "video_language", | |
| "paragraph_number", | |
| "video_script_prompt", | |
| "custom_system_prompt", | |
| "video_fit_mode", | |
| "video_concat_mode", | |
| "video_transition_mode", | |
| "video_clip_duration", | |
| "video_clip_speed", | |
| "match_materials_to_script", | |
| "n_threads", | |
| "voice_volume", | |
| "voice_rate", | |
| "custom_audio_file", | |
| "bgm_type", | |
| "bgm_file", | |
| "bgm_volume", | |
| "sonilo_bgm_prompt", | |
| "video_music_prompt", | |
| "font_name", | |
| "subtitle_position", | |
| "subtitle_display_mode", | |
| "subtitle_animation", | |
| "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 | |
| # 没有显式传入命令行参数时,使用 WebUI 保存的值。只补充上面尚未由命令行 | |
| # 设置的字段;若保存值缺失,则继续沿用 VideoParams 的默认值。 | |
| # VideoParams 里多数可保存字段(subtitle_position、custom_position 等)在 | |
| # 模型导入时就读取 config.ui,video_clip_speed 却是硬编码的 1.0,因此必须 | |
| # 在这里显式补上,否则 WebUI 保存的速度在命令行会被静默丢弃。 | |
| ui_defaults = ( | |
| ("video_clip_speed", float, _clip_speed), | |
| ("video_fit_mode", str, _video_fit_mode), | |
| ("font_name", str, None), | |
| ("subtitle_display_mode", str, _subtitle_display_mode), | |
| ("subtitle_animation", str, _subtitle_animation), | |
| ("text_fore_color", str, _hex_color), | |
| ("font_size", int, _positive_int), | |
| ("rounded_subtitle_background", bool, None), | |
| ("voice_volume", float, _non_negative_float), | |
| ("voice_rate", float, _positive_float), | |
| ("stroke_color", str, _hex_color), | |
| ("stroke_width", float, _non_negative_float), | |
| ) | |
| for name, expected_type, checker in ui_defaults: | |
| if name in params_kwargs: | |
| continue | |
| value = _ui_config_value(ui_config, name, expected_type, checker) | |
| if value is not None: | |
| params_kwargs[name] = value | |
| # 字幕位置必须由 CLI 给出确定值,不能交给上面说明的导入期字段默认值。 | |
| if "subtitle_position" not in params_kwargs: | |
| params_kwargs["subtitle_position"] = ( | |
| _ui_config_value(ui_config, "subtitle_position", str, _subtitle_position) | |
| or DEFAULT_SUBTITLE_POSITION | |
| ) | |
| if "custom_position" not in params_kwargs: | |
| saved_custom_position = _ui_config_value( | |
| ui_config, "custom_position", float, _percent_position | |
| ) | |
| params_kwargs["custom_position"] = ( | |
| DEFAULT_CUSTOM_POSITION | |
| if saved_custom_position is None | |
| else saved_custom_position | |
| ) | |
| 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: | |
| # 用户只开启了背景而没有覆盖颜色,因此优先沿用 WebUI 保存的颜色, | |
| # 只有在没有可用保存值时才回退到默认背景。 | |
| params_kwargs["text_background_color"] = ( | |
| _ui_config_value( | |
| ui_config, "subtitle_background_color", str, _hex_color | |
| ) | |
| or True | |
| ) | |
| else: | |
| # “关闭背景”加上颜色作为命令行组合是参数错误;但作为保存的设置, | |
| # 同样的组合不应中断运行,只表示禁用背景。 | |
| ui_enabled = _ui_config_value( | |
| ui_config, "subtitle_background_enabled", bool | |
| ) | |
| ui_color = _ui_config_value( | |
| ui_config, "subtitle_background_color", str, _hex_color | |
| ) | |
| if ui_enabled is False: | |
| params_kwargs["text_background_color"] = False | |
| if args.rounded_subtitle_background is None: | |
| params_kwargs["rounded_subtitle_background"] = False | |
| elif ui_color is not None: | |
| params_kwargs["text_background_color"] = ui_color | |
| elif ui_enabled is True: | |
| params_kwargs["text_background_color"] = True | |
| return VideoParams(**params_kwargs) | |
| def _load_batch_manifest(raw_path: str) -> tuple[str, list[dict[str, Any]]]: | |
| expanded_path = os.path.expanduser(raw_path.strip()) | |
| if not expanded_path: | |
| raise ValueError("--batch-file path cannot be empty") | |
| candidate = ( | |
| expanded_path | |
| if os.path.isabs(expanded_path) | |
| else os.path.join(os.getcwd(), expanded_path) | |
| ) | |
| manifest_path = os.path.realpath(candidate) | |
| if not os.path.isfile(manifest_path): | |
| raise ValueError(f"batch manifest does not exist or is not a file: {raw_path}") | |
| with open(manifest_path, "rb") as manifest_file: | |
| payload = manifest_file.read(_BATCH_FILE_MAX_BYTES + 1) | |
| if len(payload) > _BATCH_FILE_MAX_BYTES: | |
| raise ValueError("batch manifest exceeds the 1 MiB limit") | |
| try: | |
| text = payload.decode("utf-8-sig") | |
| except UnicodeDecodeError as exc: | |
| raise ValueError("batch manifest must be UTF-8 encoded") from exc | |
| if not text.strip(): | |
| raise ValueError("batch manifest must contain at least one task") | |
| if text.lstrip().startswith("["): | |
| try: | |
| parsed = json.loads(text) | |
| except json.JSONDecodeError as exc: | |
| raise ValueError( | |
| f"invalid JSON array in batch manifest: {exc.msg}" | |
| ) from exc | |
| if not isinstance(parsed, list): | |
| raise ValueError("batch manifest JSON must be an array") | |
| entries = parsed | |
| else: | |
| entries = [] | |
| for line_number, line in enumerate(text.splitlines(), start=1): | |
| if not line.strip(): | |
| continue | |
| try: | |
| entries.append(json.loads(line)) | |
| except json.JSONDecodeError as exc: | |
| raise ValueError( | |
| f"invalid JSONL in batch manifest at line {line_number}: {exc.msg}" | |
| ) from exc | |
| if not entries: | |
| raise ValueError("batch manifest must contain at least one task") | |
| if len(entries) > _BATCH_TASK_MAX_COUNT: | |
| raise ValueError( | |
| f"batch manifest contains {len(entries)} tasks; " | |
| f"the limit is {_BATCH_TASK_MAX_COUNT}" | |
| ) | |
| for index, entry in enumerate(entries, start=1): | |
| if not isinstance(entry, dict): | |
| raise ValueError(f"batch task {index} must be a JSON object") | |
| return manifest_path, entries | |
| def _validate_batch_entry_fields( | |
| entry: dict[str, Any], | |
| *, | |
| index: int, | |
| allowed_fields: set[str], | |
| ) -> None: | |
| unknown_fields = sorted(set(entry) - allowed_fields) | |
| if unknown_fields: | |
| raise ValueError( | |
| f"batch task {index} contains unknown VideoParams fields: " | |
| f"{', '.join(unknown_fields)}" | |
| ) | |
| materials = entry.get("video_materials") | |
| if not isinstance(materials, list): | |
| return | |
| allowed_material_fields = {"provider", "url", "duration", "source_info"} | |
| for material_index, material in enumerate(materials, start=1): | |
| if not isinstance(material, dict): | |
| continue | |
| unknown_material_fields = sorted(set(material) - allowed_material_fields) | |
| if unknown_material_fields: | |
| raise ValueError( | |
| f"batch task {index} material {material_index} contains unknown " | |
| f"MaterialInfo fields: {', '.join(unknown_material_fields)}" | |
| ) | |
| def _manifest_relative_path(raw_path: str, manifest_directory: str) -> str: | |
| expanded_path = os.path.expanduser(raw_path.strip()) | |
| if not expanded_path or os.path.isabs(expanded_path): | |
| return expanded_path | |
| return os.path.realpath(os.path.join(manifest_directory, expanded_path)) | |
| def _resolve_batch_entry_paths( | |
| params: VideoParams, | |
| *, | |
| override_fields: set[str], | |
| manifest_directory: str, | |
| ) -> None: | |
| if "custom_audio_file" in override_fields and params.custom_audio_file: | |
| params.custom_audio_file = _manifest_relative_path( | |
| params.custom_audio_file, | |
| manifest_directory, | |
| ) | |
| if ( | |
| "video_materials" in override_fields | |
| and params.video_source == "local" | |
| and params.video_materials | |
| ): | |
| for material in params.video_materials: | |
| material.url = _manifest_relative_path( | |
| material.url, | |
| manifest_directory, | |
| ) | |
| def _validate_batch_task_params( | |
| params: VideoParams, | |
| *, | |
| stop_at: str, | |
| custom_position_is_explicit: bool, | |
| wavespeed_charge_confirmed: bool, | |
| seedance_charge_confirmed: bool, | |
| ofox_charge_confirmed: bool, | |
| metaso_minimax_charge_confirmed: bool, | |
| muapi_charge_confirmed: bool, | |
| ) -> None: | |
| if not params.video_subject.strip() and not params.video_script.strip(): | |
| raise ValueError("one of video_subject or video_script is required") | |
| if params.video_source not in _CLI_VIDEO_SOURCES: | |
| raise ValueError( | |
| "video_source must be one of: " + ", ".join(_CLI_VIDEO_SOURCES) | |
| ) | |
| for field_name, value in ( | |
| ("video_aspect", params.video_aspect), | |
| ("video_concat_mode", params.video_concat_mode), | |
| ): | |
| # These schema fields remain Optional for compatibility with historical | |
| # API payloads, but the video pipeline always dereferences their enum | |
| # values. A manifest's explicit null must fail before any batch task starts. | |
| if value is None: | |
| raise ValueError(f"{field_name} cannot be null") | |
| if params.video_source == "local" and stop_at == "terms": | |
| raise ValueError( | |
| "stop_at=terms has no effect with video_source=local" | |
| ) | |
| if ( | |
| params.video_source == "local" | |
| and stop_at in {"materials", "video"} | |
| and not params.video_materials | |
| ): | |
| raise ValueError( | |
| "video_materials is required with video_source=local when " | |
| "stop_at is materials or video" | |
| ) | |
| if params.video_source != "local" and params.video_materials: | |
| raise ValueError("video_materials can only be used with video_source=local") | |
| if ( | |
| params.video_source == "wavespeed" | |
| and stop_at in {"materials", "video"} | |
| and not wavespeed_charge_confirmed | |
| ): | |
| raise ValueError( | |
| "--confirm-wavespeed-charge is required for WaveSpeed video generation" | |
| ) | |
| if ( | |
| params.video_source == "volcengine_seedance" | |
| and stop_at in {"materials", "video"} | |
| and not seedance_charge_confirmed | |
| ): | |
| raise ValueError( | |
| "--confirm-seedance-charge is required for Volcano Engine Seedance" | |
| ) | |
| if ( | |
| params.video_source == "ofox" | |
| and stop_at in {"materials", "video"} | |
| and not ofox_charge_confirmed | |
| ): | |
| raise ValueError("--confirm-ofox-charge is required for OFox video generation") | |
| if ( | |
| params.video_source == "metaso_minimax" | |
| and stop_at in {"materials", "video"} | |
| and not metaso_minimax_charge_confirmed | |
| ): | |
| raise ValueError( | |
| "--confirm-metaso-minimax-charge is required for Metaso MiniMax H3" | |
| ) | |
| if ( | |
| params.video_source == "muapi" | |
| and stop_at in {"materials", "video"} | |
| and not muapi_charge_confirmed | |
| ): | |
| raise ValueError("--confirm-muapi-charge is required for MuAPI video generation") | |
| if stop_at == "subtitle" and not params.subtitle_enabled: | |
| raise ValueError("stop_at=subtitle cannot be combined with disabled subtitles") | |
| if params.subtitle_position not in _SUBTITLE_POSITION_VALUES: | |
| raise ValueError( | |
| "subtitle_position must be one of: " | |
| + ", ".join(_SUBTITLE_POSITION_VALUES) | |
| ) | |
| if custom_position_is_explicit and params.subtitle_position != "custom": | |
| raise ValueError("custom_position requires subtitle_position=custom") | |
| if not math.isfinite(params.custom_position) or not 0 <= params.custom_position <= 100: | |
| raise ValueError("custom_position must be a finite number between 0 and 100") | |
| if params.video_clip_speed is not None and ( | |
| not math.isfinite(params.video_clip_speed) | |
| or not _CLIP_SPEED_MIN <= params.video_clip_speed <= _CLIP_SPEED_MAX | |
| ): | |
| raise ValueError( | |
| "video_clip_speed must be a finite number between " | |
| f"{_CLIP_SPEED_MIN} and {_CLIP_SPEED_MAX}" | |
| ) | |
| if params.text_background_color is False and params.rounded_subtitle_background: | |
| raise ValueError( | |
| "rounded_subtitle_background requires an enabled subtitle background" | |
| ) | |
| color_values = { | |
| "text_fore_color": params.text_fore_color, | |
| "stroke_color": params.stroke_color, | |
| "text_background_color": ( | |
| params.text_background_color | |
| if isinstance(params.text_background_color, str) | |
| else None | |
| ), | |
| } | |
| for name, value in color_values.items(): | |
| if value is not None and not re.fullmatch(r"#[0-9a-fA-F]{6}", value): | |
| raise ValueError(f"{name} must use #RRGGBB format") | |
| numeric_constraints = ( | |
| ("voice_volume", params.voice_volume, 0, False), | |
| ("voice_rate", params.voice_rate, 0, True), | |
| ("bgm_volume", params.bgm_volume, 0, False), | |
| ("stroke_width", params.stroke_width, 0, False), | |
| ) | |
| for name, value, minimum, exclusive in numeric_constraints: | |
| if value is None: | |
| continue | |
| if not math.isfinite(value) or ( | |
| value <= minimum if exclusive else value < minimum | |
| ): | |
| comparison = ">" if exclusive else ">=" | |
| raise ValueError(f"{name} must be a finite number {comparison} {minimum}") | |
| for name, value in ( | |
| ("n_threads", params.n_threads), | |
| ("font_size", params.font_size), | |
| ): | |
| if value is not None and value < 1: | |
| raise ValueError(f"{name} must be >= 1") | |
| if params.bgm_type not in {"", "random", "custom", *_CLI_MUSIC_BGM_TYPES}: | |
| raise ValueError(f"bgm_type must be one of: {_BGM_TYPE_CHOICES_TEXT}") | |
| if params.bgm_file and params.bgm_type != "custom": | |
| raise ValueError("bgm_file requires bgm_type=custom") | |
| if params.sonilo_bgm_prompt and params.bgm_type != "sonilo": | |
| raise ValueError("sonilo_bgm_prompt requires bgm_type=sonilo") | |
| if params.video_music_prompt and params.bgm_type not in _CLI_MUSIC_BGM_TYPES: | |
| raise ValueError( | |
| "video_music_prompt requires bgm_type=sonilo or elevenlabs" | |
| ) | |
| def _build_batch_tasks(args: argparse.Namespace) -> list[VideoParams]: | |
| from app.models.schema import VideoParams | |
| manifest_path, entries = _load_batch_manifest(args.batch_file) | |
| manifest_directory = os.path.dirname(manifest_path) | |
| base_params = build_video_params(args) | |
| allowed_fields = set(VideoParams.model_fields) | |
| base_values = { | |
| field_name: getattr(base_params, field_name) for field_name in allowed_fields | |
| } | |
| tasks: list[VideoParams] = [] | |
| for index, entry in enumerate(entries, start=1): | |
| _validate_batch_entry_fields( | |
| entry, | |
| index=index, | |
| allowed_fields=allowed_fields, | |
| ) | |
| try: | |
| params = VideoParams.model_validate( | |
| {**copy.deepcopy(base_values), **entry} | |
| ) | |
| override_fields = set(entry) | |
| _resolve_batch_entry_paths( | |
| params, | |
| override_fields=override_fields, | |
| manifest_directory=manifest_directory, | |
| ) | |
| _validate_batch_task_params( | |
| params, | |
| stop_at=args.stop_at, | |
| custom_position_is_explicit=( | |
| args.custom_position is not None | |
| or "custom_position" in override_fields | |
| ), | |
| seedance_charge_confirmed=args.confirm_seedance_charge, | |
| wavespeed_charge_confirmed=args.confirm_wavespeed_charge, | |
| ofox_charge_confirmed=args.confirm_ofox_charge, | |
| metaso_minimax_charge_confirmed=( | |
| args.confirm_metaso_minimax_charge | |
| ), | |
| muapi_charge_confirmed=args.confirm_muapi_charge, | |
| ) | |
| except (TypeError, ValueError) as exc: | |
| raise ValueError(f"invalid batch task {index}: {exc}") from exc | |
| tasks.append(params) | |
| validation_plans = [] | |
| for index, params in enumerate(tasks, start=1): | |
| try: | |
| validation_plans.append(_validate_cli_files(params, stop_at=args.stop_at)) | |
| except (OSError, ValueError) as exc: | |
| raise ValueError(f"invalid batch task {index}: {exc}") from exc | |
| prepared_paths: dict[str, str] = {} | |
| created_paths: list[str] = [] | |
| try: | |
| for index, (local_videos_dir, resolved_materials) in enumerate( | |
| validation_plans, start=1 | |
| ): | |
| try: | |
| _prepare_cli_materials( | |
| local_videos_dir, | |
| resolved_materials, | |
| prepared_paths=prepared_paths, | |
| created_paths=created_paths, | |
| ) | |
| except OSError as exc: | |
| raise ValueError(f"invalid batch task {index}: {exc}") from exc | |
| except Exception: | |
| _remove_cli_material_copies(created_paths) | |
| raise | |
| return tasks | |
| 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 _validate_cli_files( | |
| params: VideoParams, stop_at: str | |
| ) -> tuple[str, list[tuple[MaterialInfo, str, str]]]: | |
| """ | |
| 无副作用地解析并校验 CLI 文件,避免批量预检留下素材副本。 | |
| 自定义音频、BGM 和字体会被规范化为服务层可使用的路径或名称;本地素材 | |
| 仅解析来源和扩展名,实际复制由 ``_prepare_cli_materials`` 在所有批量条目 | |
| 校验通过后统一完成。 | |
| """ | |
| from app.models import const | |
| from app.services import bgm as bgm_service | |
| from app.utils import utils | |
| # FFmpeg 探测已经移到 app/services/task.py 的共享任务流水线(task.start) | |
| # 里统一做硬性检查:探测失败会让任务以 preflight 阶段失败结束,run_cli() | |
| # 会据此返回非零退出码。这里不再重复一次不阻断流程的检查,避免与流水线 | |
| # 里的判断结果不一致。 | |
| 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 and stop_at not in {"script", "terms"}: | |
| 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 stop_at == "video": | |
| if not bgm_service.should_use_bgm(params.bgm_type, params.bgm_volume): | |
| # 0 音量时下游会统一跳过所有 BGM。这里同时清空文件参数,避免 | |
| # CLI 为一个不会被读取的文件执行路径解析、存在性检查或格式 | |
| # 校验。 | |
| params.bgm_file = "" | |
| elif not params.bgm_file: | |
| # 缺少文件是否构成错误取决于通用 BGM 开关,不能在 argparse 阶段 | |
| # 无条件拦截,否则 ``custom + 0%`` 会和 WebUI、服务层行为不一致。 | |
| raise ValueError("--bgm-file is required when --bgm-type is custom") | |
| else: | |
| try: | |
| # CLI、WebUI 和任务服务必须共用同一个 BGM 文件边界。这里直接 | |
| # 复用服务层解析,既支持用户上传目录和内置歌曲目录,也 | |
| # 自动继承新增音频格式及路径安全规则,避免多个入口分别 | |
| # 维护白名单。 | |
| params.bgm_file = bgm_service.resolve_bgm_file(params.bgm_file) | |
| except ValueError as exc: | |
| supported_extensions = ", ".join( | |
| bgm_service.SUPPORTED_BGM_EXTENSIONS | |
| ) | |
| raise ValueError( | |
| "background music must be a supported audio file inside " | |
| f"storage/bgm or resource/songs ({supported_extensions}): " | |
| f"{params.bgm_file}" | |
| ) from exc | |
| 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") | |
| 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)) | |
| return local_videos_dir, resolved_materials | |
| def _remove_cli_material_copies(created_paths: Sequence[str]) -> None: | |
| """Best-effort cleanup for managed copies created by one CLI preparation.""" | |
| for file_path in reversed(created_paths): | |
| try: | |
| if os.path.isfile(file_path): | |
| os.remove(file_path) | |
| except OSError as exc: | |
| logger.warning( | |
| f"failed to remove prepared CLI material: path={file_path}, " | |
| f"error={exc}" | |
| ) | |
| def _prepare_cli_materials( | |
| local_videos_dir: str, | |
| resolved_materials: Sequence[tuple[MaterialInfo, str, str]], | |
| *, | |
| prepared_paths: dict[str, str] | None = None, | |
| created_paths: list[str] | None = None, | |
| ) -> None: | |
| """Copy validated local materials once and update every matching reference.""" | |
| if not resolved_materials: | |
| return | |
| os.makedirs(local_videos_dir, exist_ok=True) | |
| if prepared_paths is None: | |
| prepared_paths = {} | |
| if created_paths is None: | |
| created_paths = [] | |
| 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}", | |
| ) | |
| try: | |
| shutil.copy2(source_path, prepared_path) | |
| except OSError: | |
| try: | |
| if os.path.exists(prepared_path): | |
| os.remove(prepared_path) | |
| except OSError: | |
| pass | |
| raise | |
| created_paths.append(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 prepare_cli_files(params: VideoParams, stop_at: str) -> None: | |
| """Validate and prepare files for one trusted local CLI task.""" | |
| local_videos_dir, resolved_materials = _validate_cli_files(params, stop_at) | |
| created_paths: list[str] = [] | |
| try: | |
| _prepare_cli_materials( | |
| local_videos_dir, | |
| resolved_materials, | |
| created_paths=created_paths, | |
| ) | |
| except Exception: | |
| _remove_cli_material_copies(created_paths) | |
| raise | |
| def _run_batch_tasks(args: argparse.Namespace, tasks: list[VideoParams]) -> int: | |
| from app.services import task as tm | |
| from app.utils import utils | |
| task_summaries: list[dict[str, Any]] = [] | |
| used_task_ids: set[str] = set() | |
| failed_count = 0 | |
| for index, params in enumerate(tasks, start=1): | |
| task_id = utils.get_uuid() | |
| if task_id in used_task_ids: | |
| task_id = str(uuid4()) | |
| used_task_ids.add(task_id) | |
| logger.info( | |
| f"start CLI batch task: index={index}, task_id={task_id}, " | |
| f"stop_at={args.stop_at}" | |
| ) | |
| result = None | |
| failed_stage = None | |
| error = None | |
| try: | |
| result = tm.start( | |
| task_id=task_id, | |
| params=params, | |
| stop_at=args.stop_at, | |
| allow_server_file_input=True, | |
| ) | |
| except Exception as exc: | |
| failed_stage = "runtime" | |
| error = str(exc) | |
| logger.exception( | |
| "CLI batch task failed with an unexpected error: " | |
| f"index={index}, task_id={task_id}, error={exc}" | |
| ) | |
| else: | |
| if not isinstance(result, dict) or not result: | |
| failed_stage = "unknown" | |
| error = "empty or invalid task result" | |
| elif result.get("state") == tm.const.TASK_STATE_FAILED: | |
| failed_stage = result.get("failed_stage") or "unknown" | |
| error = result.get("error") or "unknown task error" | |
| if error is not None: | |
| logger.error( | |
| f"CLI batch task failed: index={index}, task_id={task_id}, " | |
| f"stop_at={args.stop_at}, stage={failed_stage}, error={error}" | |
| ) | |
| status = "failed" if error is not None else "succeeded" | |
| if status == "failed": | |
| failed_count += 1 | |
| task_summaries.append( | |
| { | |
| "index": index, | |
| "task_id": task_id, | |
| "status": status, | |
| "result": result, | |
| "failed_stage": failed_stage, | |
| "error": error, | |
| } | |
| ) | |
| print( | |
| json.dumps( | |
| { | |
| "total": len(task_summaries), | |
| "succeeded": len(task_summaries) - failed_count, | |
| "failed": failed_count, | |
| "tasks": task_summaries, | |
| }, | |
| ensure_ascii=False, | |
| ) | |
| ) | |
| return 1 if failed_count else 0 | |
| def run_cli(argv: Sequence[str] | None = None) -> int: | |
| args = parse_args(argv) | |
| if args.batch_file: | |
| try: | |
| tasks = _build_batch_tasks(args) | |
| except (ValueError, OSError) as exc: | |
| logger.error(f"invalid CLI batch input: {exc}") | |
| return 2 | |
| return _run_batch_tasks(args, tasks) | |
| 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, | |
| # CLI inputs come from the local operator rather than an HTTP client. | |
| # Preserve support for arbitrary local audio paths without weakening the | |
| # task service's secure default for API and WebUI callers. | |
| allow_server_file_input=True, | |
| ) | |
| 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 or result.get("state") == tm.const.TASK_STATE_FAILED: | |
| failed_stage = result.get("failed_stage", "unknown") if result else "unknown" | |
| error = result.get("error", "unknown task error") if result else "empty result" | |
| logger.error( | |
| f"CLI task failed: task_id={task_id}, stop_at={args.stop_at}, " | |
| f"stage={failed_stage}, error={error}" | |
| ) | |
| if result and result.get("videos"): | |
| print(json.dumps({"task_id": task_id, "result": result}, ensure_ascii=False)) | |
| return 1 | |
| print(json.dumps({"task_id": task_id, "result": result}, ensure_ascii=False)) | |
| return 0 | |
| def _force_utf8_console() -> None: | |
| """Make stdout/stderr UTF-8 before anything is printed. | |
| Windows consoles default to a legacy code page (cp1252 in Western | |
| Europe). Generating a French video produces U+202F, the narrow no-break | |
| space French typography puts before ':' and '!', and Loguru's own progress | |
| lines carry circled digits. Either one raises UnicodeEncodeError, which | |
| kills the process *after* the video was written successfully -- so the run | |
| reports failure and never prints where the file is. | |
| """ | |
| for stream in (sys.stdout, sys.stderr): | |
| reconfigure = getattr(stream, "reconfigure", None) | |
| if reconfigure is None: | |
| continue | |
| try: | |
| reconfigure(encoding="utf-8", errors="replace") | |
| except (ValueError, OSError): | |
| continue | |
| if __name__ == "__main__": | |
| _force_utf8_console() | |
| raise SystemExit(run_cli()) | |