| 1 | import argparse |
| 2 | import json |
| 3 | import re |
| 4 | from typing import Sequence |
| 5 | |
| 6 | from loguru import logger |
| 7 | |
| 8 | from app.models.schema import MaterialInfo, VideoParams |
| 9 | from app.services import task as tm |
| 10 | from app.utils import utils |
| 11 | |
| 12 | |
| 13 | def _positive_int(value: str) -> int: |
| 14 | parsed = int(value) |
| 15 | if parsed < 1: |
| 16 | raise argparse.ArgumentTypeError(f"value must be >= 1, got {parsed}") |
| 17 | return parsed |
| 18 | |
| 19 | |
| 20 | def _paragraph_count(value: str) -> int: |
| 21 | parsed = int(value) |
| 22 | if parsed < 1 or parsed > 10: |
| 23 | raise argparse.ArgumentTypeError( |
| 24 | f"paragraph-number must be between 1 and 10, got {parsed}" |
| 25 | ) |
| 26 | return parsed |
| 27 | |
| 28 | |
| 29 | def _non_negative_float(value: str) -> float: |
| 30 | parsed = float(value) |
| 31 | if parsed < 0: |
| 32 | raise argparse.ArgumentTypeError(f"value must be >= 0, got {parsed}") |
| 33 | return parsed |
| 34 | |
| 35 | |
| 36 | def _percent_position(value: str) -> float: |
| 37 | parsed = float(value) |
| 38 | if parsed < 0 or parsed > 100: |
| 39 | raise argparse.ArgumentTypeError( |
| 40 | f"custom-position must be between 0 and 100, got {parsed}" |
| 41 | ) |
| 42 | return parsed |
| 43 | |
| 44 | |
| 45 | def _hex_color(value: str) -> str: |
| 46 | if not re.fullmatch(r"#[0-9a-fA-F]{6}", value): |
| 47 | raise argparse.ArgumentTypeError( |
| 48 | f"color must use #RRGGBB format, got {value!r}" |
| 49 | ) |
| 50 | return value |
| 51 | |
| 52 | |
| 53 | _TRANSITION_MODE_VALUES = { |
| 54 | "none": None, |
| 55 | "shuffle": "Shuffle", |
| 56 | "fade-in": "FadeIn", |
| 57 | "fade-out": "FadeOut", |
| 58 | "slide-in": "SlideIn", |
| 59 | "slide-out": "SlideOut", |
| 60 | } |
| 61 | |
| 62 | |
| 63 | def _transition_mode(value: str) -> str | None: |
| 64 | normalized = value.strip().lower() |
| 65 | if normalized not in _TRANSITION_MODE_VALUES: |
| 66 | allowed = ", ".join(_TRANSITION_MODE_VALUES) |
| 67 | raise argparse.ArgumentTypeError( |
| 68 | f"video-transition-mode must be one of: {allowed}" |
| 69 | ) |
| 70 | return _TRANSITION_MODE_VALUES[normalized] |
| 71 | |
| 72 | |
| 73 | def _bgm_type(value: str) -> str: |
| 74 | normalized = value.strip().lower() |
| 75 | if normalized == "none": |
| 76 | return "" |
| 77 | if normalized in {"", "random", "custom"}: |
| 78 | return normalized |
| 79 | raise argparse.ArgumentTypeError("bgm-type must be one of: none, random, custom") |
| 80 | |
| 81 | |
| 82 | def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace: |
| 83 | parser = argparse.ArgumentParser( |
| 84 | description="MoneyPrinterTurbo command line video generation" |
| 85 | ) |
| 86 | parser.add_argument("--video-subject", required=True, help="video subject") |
| 87 | parser.add_argument("--video-script", default="", help="custom script") |
| 88 | parser.add_argument("--video-terms", default=None, help="comma-separated terms") |
| 89 | parser.add_argument( |
| 90 | "--video-language", |
| 91 | default=None, |
| 92 | help="script generation language code (default: auto detect)", |
| 93 | ) |
| 94 | parser.add_argument( |
| 95 | "--paragraph-number", |
| 96 | type=_paragraph_count, |
| 97 | default=None, |
| 98 | help="script paragraph count, 1-10", |
| 99 | ) |
| 100 | parser.add_argument( |
| 101 | "--video-script-prompt", |
| 102 | default=None, |
| 103 | help="custom script requirements prompt", |
| 104 | ) |
| 105 | parser.add_argument( |
| 106 | "--custom-system-prompt", |
| 107 | default=None, |
| 108 | help="custom system prompt for script generation", |
| 109 | ) |
| 110 | parser.add_argument( |
| 111 | "--video-source", |
| 112 | default="pexels", |
| 113 | choices=["pexels", "pixabay", "coverr", "local"], |
| 114 | help="video material source", |
| 115 | ) |
| 116 | parser.add_argument( |
| 117 | "--video-materials", |
| 118 | default="", |
| 119 | help="comma-separated local material paths", |
| 120 | ) |
| 121 | parser.add_argument( |
| 122 | "--stop-at", |
| 123 | default="video", |
| 124 | choices=["script", "terms", "audio", "subtitle", "materials", "video"], |
| 125 | help="pipeline stop stage", |
| 126 | ) |
| 127 | parser.add_argument( |
| 128 | "--video-count", type=_positive_int, default=1, help="output video count (>=1)" |
| 129 | ) |
| 130 | parser.add_argument("--video-aspect", default="9:16", help="video aspect ratio") |
| 131 | parser.add_argument( |
| 132 | "--video-concat-mode", |
| 133 | choices=["random", "sequential"], |
| 134 | default=None, |
| 135 | help="video concatenation mode", |
| 136 | ) |
| 137 | parser.add_argument( |
| 138 | "--video-transition-mode", |
| 139 | type=_transition_mode, |
| 140 | default=None, |
| 141 | metavar="{none,shuffle,fade-in,fade-out,slide-in,slide-out}", |
| 142 | help="video transition mode", |
| 143 | ) |
| 144 | parser.add_argument( |
| 145 | "--video-clip-duration", |
| 146 | type=_positive_int, |
| 147 | default=None, |
| 148 | help="maximum duration of each source clip in seconds", |
| 149 | ) |
| 150 | parser.add_argument( |
| 151 | "--match-materials-to-script", |
| 152 | default=None, |
| 153 | action=argparse.BooleanOptionalAction, |
| 154 | help="match generated/search materials to script order", |
| 155 | ) |
| 156 | parser.add_argument("--voice-name", default="", help="tts voice name") |
| 157 | parser.add_argument( |
| 158 | "--voice-volume", |
| 159 | type=_non_negative_float, |
| 160 | default=None, |
| 161 | help="speech volume multiplier", |
| 162 | ) |
| 163 | parser.add_argument( |
| 164 | "--voice-rate", |
| 165 | type=_non_negative_float, |
| 166 | default=None, |
| 167 | help="speech rate multiplier", |
| 168 | ) |
| 169 | parser.add_argument( |
| 170 | "--bgm-type", |
| 171 | type=_bgm_type, |
| 172 | default=None, |
| 173 | metavar="{none,random,custom}", |
| 174 | help="background music mode", |
| 175 | ) |
| 176 | parser.add_argument("--bgm-file", default=None, help="custom background music file") |
| 177 | parser.add_argument( |
| 178 | "--bgm-volume", |
| 179 | type=_non_negative_float, |
| 180 | default=None, |
| 181 | help="background music volume multiplier", |
| 182 | ) |
| 183 | parser.add_argument( |
| 184 | "--subtitle-enabled", |
| 185 | default=True, |
| 186 | action=argparse.BooleanOptionalAction, |
| 187 | help="enable subtitles (default: enabled, use --no-subtitle-enabled to disable)", |
| 188 | ) |
| 189 | parser.add_argument("--font-name", default=None, help="subtitle font file name") |
| 190 | parser.add_argument( |
| 191 | "--subtitle-position", |
| 192 | choices=["top", "center", "bottom", "custom"], |
| 193 | default=None, |
| 194 | help="subtitle position", |
| 195 | ) |
| 196 | parser.add_argument( |
| 197 | "--custom-position", |
| 198 | type=_percent_position, |
| 199 | default=None, |
| 200 | help="custom subtitle position as percent from top, 0-100", |
| 201 | ) |
| 202 | parser.add_argument( |
| 203 | "--text-fore-color", |
| 204 | type=_hex_color, |
| 205 | default=None, |
| 206 | help="subtitle text color in #RRGGBB format", |
| 207 | ) |
| 208 | parser.add_argument( |
| 209 | "--font-size", type=_positive_int, default=None, help="subtitle font size" |
| 210 | ) |
| 211 | parser.add_argument( |
| 212 | "--stroke-color", |
| 213 | type=_hex_color, |
| 214 | default=None, |
| 215 | help="subtitle outline color in #RRGGBB format", |
| 216 | ) |
| 217 | parser.add_argument( |
| 218 | "--stroke-width", |
| 219 | type=_non_negative_float, |
| 220 | default=None, |
| 221 | help="subtitle outline width", |
| 222 | ) |
| 223 | parser.add_argument( |
| 224 | "--subtitle-background-enabled", |
| 225 | default=None, |
| 226 | action=argparse.BooleanOptionalAction, |
| 227 | help="enable subtitle background", |
| 228 | ) |
| 229 | parser.add_argument( |
| 230 | "--subtitle-background-color", |
| 231 | type=_hex_color, |
| 232 | default=None, |
| 233 | help="subtitle background color in #RRGGBB format", |
| 234 | ) |
| 235 | parser.add_argument( |
| 236 | "--rounded-subtitle-background", |
| 237 | default=None, |
| 238 | action=argparse.BooleanOptionalAction, |
| 239 | help="enable rounded translucent subtitle background", |
| 240 | ) |
| 241 | parser.add_argument("--task-id", default="", help="custom task id") |
| 242 | args = parser.parse_args(argv) |
| 243 | |
| 244 | if args.video_source == "local" and not (args.video_materials or "").strip(): |
| 245 | parser.error("--video-materials is required when --video-source is local") |
| 246 | |
| 247 | if args.video_source == "local" and args.stop_at == "terms": |
| 248 | parser.error( |
| 249 | "--stop-at terms has no effect with --video-source local " |
| 250 | "(search terms are not generated for local sources)" |
| 251 | ) |
| 252 | |
| 253 | return args |
| 254 | |
| 255 | |
| 256 | def build_video_params(args: argparse.Namespace) -> VideoParams: |
| 257 | video_terms = args.video_terms |
| 258 | if video_terms: |
| 259 | video_terms = [term.strip() for term in video_terms.split(",") if term.strip()] |
| 260 | |
| 261 | video_materials = None |
| 262 | materials_arg = args.video_materials or "" |
| 263 | if materials_arg.strip(): |
| 264 | video_materials = [ |
| 265 | # Actual duration will be detected during video processing; use 0 as placeholder. |
| 266 | MaterialInfo(provider="local", url=item.strip(), duration=0) |
| 267 | for item in materials_arg.split(",") |
| 268 | if item.strip() |
| 269 | ] |
| 270 | |
| 271 | params_kwargs = { |
| 272 | "video_subject": args.video_subject, |
| 273 | "video_script": args.video_script, |
| 274 | "video_terms": video_terms, |
| 275 | "video_source": args.video_source, |
| 276 | "video_materials": video_materials, |
| 277 | "video_count": args.video_count, |
| 278 | "video_aspect": args.video_aspect, |
| 279 | "voice_name": args.voice_name, |
| 280 | "subtitle_enabled": args.subtitle_enabled, |
| 281 | } |
| 282 | |
| 283 | optional_arg_names = [ |
| 284 | "video_language", |
| 285 | "paragraph_number", |
| 286 | "video_script_prompt", |
| 287 | "custom_system_prompt", |
| 288 | "video_concat_mode", |
| 289 | "video_transition_mode", |
| 290 | "video_clip_duration", |
| 291 | "match_materials_to_script", |
| 292 | "voice_volume", |
| 293 | "voice_rate", |
| 294 | "bgm_type", |
| 295 | "bgm_file", |
| 296 | "bgm_volume", |
| 297 | "font_name", |
| 298 | "subtitle_position", |
| 299 | "custom_position", |
| 300 | "text_fore_color", |
| 301 | "font_size", |
| 302 | "stroke_color", |
| 303 | "stroke_width", |
| 304 | "rounded_subtitle_background", |
| 305 | ] |
| 306 | for name in optional_arg_names: |
| 307 | value = getattr(args, name) |
| 308 | if value is not None: |
| 309 | params_kwargs[name] = value |
| 310 | |
| 311 | if args.subtitle_background_enabled is False: |
| 312 | params_kwargs["text_background_color"] = False |
| 313 | params_kwargs["rounded_subtitle_background"] = False |
| 314 | elif args.subtitle_background_color is not None: |
| 315 | params_kwargs["text_background_color"] = args.subtitle_background_color |
| 316 | elif args.subtitle_background_enabled is True: |
| 317 | params_kwargs["text_background_color"] = True |
| 318 | |
| 319 | return VideoParams(**params_kwargs) |
| 320 | |
| 321 | |
| 322 | def run_cli(argv: Sequence[str] | None = None) -> int: |
| 323 | args = parse_args(argv) |
| 324 | params = build_video_params(args) |
| 325 | task_id = args.task_id or utils.get_uuid() |
| 326 | logger.info(f"start cli task: {task_id}, stop_at: {args.stop_at}") |
| 327 | result = tm.start(task_id=task_id, params=params, stop_at=args.stop_at) |
| 328 | if not result: |
| 329 | logger.error("video generation failed") |
| 330 | return 1 |
| 331 | |
| 332 | print(json.dumps({"task_id": task_id, "result": result}, ensure_ascii=False)) |
| 333 | return 0 |
| 334 | |
| 335 | |
| 336 | if __name__ == "__main__": |
| 337 | raise SystemExit(run_cli()) |
| 338 |