| 1 | """ |
| 2 | 统一视频生成客户端 |
| 3 | 根据 model 名称自动路由到对应后端: |
| 4 | - wan* → DashscopeVideoClient (DashScope VideoSynthesis) |
| 5 | - kling* → KlingVideoClient (可灵 AI) |
| 6 | """ |
| 7 | |
| 8 | import os |
| 9 | import sys |
| 10 | |
| 11 | models_dir = os.path.dirname(os.path.abspath(__file__)) |
| 12 | backend_dir = os.path.dirname(models_dir) |
| 13 | if backend_dir not in sys.path: |
| 14 | sys.path.insert(0, backend_dir) |
| 15 | |
| 16 | import logging |
| 17 | from typing import Optional |
| 18 | from config import Config |
| 19 | |
| 20 | try: |
| 21 | from models.video_dashscope import DashscopeVideoClient |
| 22 | from models.video_kling import KlingVideoClient |
| 23 | from models.video_seedance import SeedanceVideoClient |
| 24 | except ImportError: |
| 25 | from video_dashscope import DashscopeVideoClient |
| 26 | from video_kling import KlingVideoClient |
| 27 | from video_seedance import SeedanceVideoClient |
| 28 | |
| 29 | logger = logging.getLogger(__name__) |
| 30 | |
| 31 | |
| 32 | class VideoClient: |
| 33 | """ |
| 34 | 统一视频生成客户端 |
| 35 | 参照 ImageClient 模式,按模型名路由到不同后端 |
| 36 | """ |
| 37 | |
| 38 | def __init__( |
| 39 | self, |
| 40 | dashscope_api_key: Optional[str] = None, |
| 41 | dashscope_base_url: Optional[str] = None, |
| 42 | kling_api_key: Optional[str] = None, |
| 43 | kling_base_url: Optional[str] = None, |
| 44 | ark_api_key: Optional[str] = None, |
| 45 | ark_base_url: Optional[str] = None, |
| 46 | ): |
| 47 | self._dashscope_api_key = dashscope_api_key or Config.DASHSCOPE_API_KEY |
| 48 | self._dashscope_base_url = dashscope_base_url or Config.DASHSCOPE_BASE_URL |
| 49 | self._kling_api_key = kling_api_key or Config.KLING_API_KEY |
| 50 | self._kling_base_url = kling_base_url or Config.KLING_BASE_URL |
| 51 | self._ark_api_key = ark_api_key or Config.ARK_API_KEY |
| 52 | self._ark_base_url = ark_base_url or Config.ARK_BASE_URL |
| 53 | |
| 54 | self._dashscope_client = None |
| 55 | self._kling_client = None |
| 56 | self._seedance_client = None |
| 57 | |
| 58 | @property |
| 59 | def Dashscope_client(self): |
| 60 | if self._dashscope_client is None: |
| 61 | self._dashscope_client = DashscopeVideoClient( |
| 62 | api_key=self._dashscope_api_key, |
| 63 | base_url=self._dashscope_base_url, |
| 64 | ) |
| 65 | return self._dashscope_client |
| 66 | |
| 67 | @property |
| 68 | def kling_client(self): |
| 69 | if self._kling_client is None: |
| 70 | self._kling_client = KlingVideoClient( |
| 71 | api_key=self._kling_api_key, |
| 72 | base_url=self._kling_base_url, |
| 73 | ) |
| 74 | return self._kling_client |
| 75 | |
| 76 | @property |
| 77 | def seedance_client(self): |
| 78 | if self._seedance_client is None: |
| 79 | self._seedance_client = SeedanceVideoClient( |
| 80 | api_key=self._ark_api_key, |
| 81 | base_url=self._ark_base_url, |
| 82 | ) |
| 83 | return self._seedance_client |
| 84 | |
| 85 | def generate_video( |
| 86 | self, |
| 87 | prompt: str, |
| 88 | image_path: Optional[str], |
| 89 | save_path: str, |
| 90 | model: str = "wan2.7-i2v", |
| 91 | duration: int = 5, |
| 92 | shot_type: str = "multi", |
| 93 | sound: str = "", |
| 94 | video_ratio: str = "16:9", |
| 95 | resolution: Optional[str] = None, |
| 96 | last_image_path: Optional[str] = None, |
| 97 | first_clip_path: Optional[str] = None, |
| 98 | reference_image_path: Optional[str] = None, |
| 99 | reference_image_paths: Optional[list[str]] = None, |
| 100 | reference_video_paths: Optional[list[str]] = None, |
| 101 | reference_audio_path: Optional[str] = None, |
| 102 | audio_path: Optional[str] = None, |
| 103 | negative_prompt: Optional[str] = None, |
| 104 | prompt_extend: Optional[bool] = None, |
| 105 | watermark: Optional[bool] = None, |
| 106 | seed: Optional[int] = None, |
| 107 | mode: str = "pro", |
| 108 | cfg_scale: float = 0.5, |
| 109 | generate_audio: Optional[bool] = None, |
| 110 | audio: Optional[bool] = None, |
| 111 | ) -> str: |
| 112 | """ |
| 113 | 生成视频 |
| 114 | |
| 115 | Args: |
| 116 | prompt: 视频描述提示词 |
| 117 | image_path: 输入图片本地路径;DashScope wan2.7 视频续写可为空并使用 first_clip_path |
| 118 | save_path: 输出视频保存路径 |
| 119 | model: 模型名,决定使用哪个后端 |
| 120 | duration: 视频时长(秒) |
| 121 | shot_type: 镜头类型 "single" / "multi" |
| 122 | |
| 123 | Returns: |
| 124 | video_url: 远端视频 URL |
| 125 | |
| 126 | Raises: |
| 127 | FileNotFoundError: 输入图片不存在 |
| 128 | RuntimeError: 生成或下载失败 |
| 129 | """ |
| 130 | if not model: |
| 131 | model = "wan2.7-i2v" |
| 132 | |
| 133 | # 确保 duration 是整数,视频模型通常要求整数秒 |
| 134 | duration = int(duration) |
| 135 | |
| 136 | if Config.PRINT_MODEL_INPUT: |
| 137 | lines = [ |
| 138 | "---- VIDEO GENERATION REQUEST ----", |
| 139 | f"Prompt: {prompt}", |
| 140 | "Image: [Base64图片]" if image_path and str(image_path).startswith("data:") else f"Image: {image_path}", |
| 141 | f"Model: {model}", |
| 142 | f"Duration: {duration}s", |
| 143 | f"Shot Type: {shot_type}", |
| 144 | f"Video Ratio: {video_ratio}", |
| 145 | ] |
| 146 | if resolution: |
| 147 | lines.append(f"Resolution: {resolution}") |
| 148 | if last_image_path: |
| 149 | lines.append(f"Last Image: {last_image_path}") |
| 150 | if first_clip_path: |
| 151 | lines.append(f"First Clip: {first_clip_path}") |
| 152 | if reference_image_path: |
| 153 | lines.append(f"Reference Image: {reference_image_path}") |
| 154 | if reference_image_paths: |
| 155 | lines.append(f"Reference Images: {reference_image_paths}") |
| 156 | if reference_video_paths: |
| 157 | lines.append(f"Reference Videos: {reference_video_paths}") |
| 158 | if reference_audio_path: |
| 159 | lines.append(f"Reference Audio: {reference_audio_path}") |
| 160 | if audio_path: |
| 161 | lines.append(f"Audio: {audio_path}") |
| 162 | if negative_prompt: |
| 163 | lines.append(f"Negative Prompt: {negative_prompt}") |
| 164 | if sound: |
| 165 | lines.append(f"Sound: {sound}") |
| 166 | lines.extend([ |
| 167 | f"Save: {save_path}", |
| 168 | "-" * 30, |
| 169 | ]) |
| 170 | logger.info("\n%s", "\n".join(lines)) |
| 171 | |
| 172 | model_lower = model.lower() |
| 173 | |
| 174 | if "kling" in model_lower: |
| 175 | return self._generate_kling( |
| 176 | prompt, |
| 177 | image_path, |
| 178 | save_path, |
| 179 | model, |
| 180 | duration, |
| 181 | sound, |
| 182 | video_ratio, |
| 183 | resolution, |
| 184 | mode, |
| 185 | cfg_scale, |
| 186 | negative_prompt or "", |
| 187 | ) |
| 188 | elif "seedance" in model_lower: |
| 189 | return self._generate_seedance( |
| 190 | prompt, |
| 191 | image_path, |
| 192 | save_path, |
| 193 | model, |
| 194 | duration, |
| 195 | video_ratio, |
| 196 | resolution, |
| 197 | seed, |
| 198 | watermark, |
| 199 | generate_audio, |
| 200 | ) |
| 201 | elif "wan" in model_lower or "happyhorse" in model_lower: |
| 202 | return self._generate_wan( |
| 203 | prompt, |
| 204 | image_path, |
| 205 | save_path, |
| 206 | model, |
| 207 | duration, |
| 208 | shot_type, |
| 209 | video_ratio, |
| 210 | last_image_path, |
| 211 | first_clip_path, |
| 212 | reference_image_path, |
| 213 | reference_image_paths, |
| 214 | reference_video_paths, |
| 215 | reference_audio_path, |
| 216 | audio_path, |
| 217 | negative_prompt, |
| 218 | resolution, |
| 219 | prompt_extend, |
| 220 | watermark if watermark is not None else False, |
| 221 | seed, |
| 222 | audio, |
| 223 | ) |
| 224 | else: |
| 225 | raise ValueError(f"未知的视频生成模型: {model}") |
| 226 | |
| 227 | @staticmethod |
| 228 | def _normalize_seedance_resolution(resolution: Optional[str]) -> str: |
| 229 | value = (resolution or "720p").strip().lower() |
| 230 | return value if value in {"720p", "1080p"} else "720p" |
| 231 | |
| 232 | def _generate_wan( |
| 233 | self, |
| 234 | prompt: str, |
| 235 | image_path: Optional[str], |
| 236 | save_path: str, |
| 237 | model: str, |
| 238 | duration: int, |
| 239 | shot_type: str, |
| 240 | video_ratio: str, |
| 241 | last_image_path: Optional[str], |
| 242 | first_clip_path: Optional[str], |
| 243 | reference_image_path: Optional[str], |
| 244 | reference_image_paths: Optional[list[str]], |
| 245 | reference_video_paths: Optional[list[str]], |
| 246 | reference_audio_path: Optional[str], |
| 247 | audio_path: Optional[str], |
| 248 | negative_prompt: Optional[str], |
| 249 | resolution: Optional[str], |
| 250 | prompt_extend: Optional[bool], |
| 251 | watermark: bool, |
| 252 | seed: Optional[int], |
| 253 | audio: Optional[bool], |
| 254 | ) -> str: |
| 255 | """通过万象模型生成视频""" |
| 256 | # 设置 Wan 和 Happyhorse 系列视频生成的默认分辨率为 720P |
| 257 | resolution = resolution or "720P" |
| 258 | |
| 259 | logger.info("VideoClient routed to Wan: model=%s", model) |
| 260 | return self.Dashscope_client.generate_video( |
| 261 | prompt=prompt, |
| 262 | image_path=image_path, |
| 263 | save_path=save_path, |
| 264 | model=model, |
| 265 | duration=duration, |
| 266 | shot_type=shot_type, |
| 267 | video_ratio=video_ratio, |
| 268 | last_image_path=last_image_path, |
| 269 | first_clip_path=first_clip_path, |
| 270 | reference_image_path=reference_image_path, |
| 271 | reference_image_paths=reference_image_paths, |
| 272 | reference_video_paths=reference_video_paths, |
| 273 | reference_audio_path=reference_audio_path, |
| 274 | audio_path=audio_path, |
| 275 | negative_prompt=negative_prompt, |
| 276 | resolution=resolution, |
| 277 | prompt_extend=prompt_extend, |
| 278 | watermark=watermark, |
| 279 | seed=seed, |
| 280 | audio=audio, |
| 281 | ) |
| 282 | |
| 283 | def _generate_kling( |
| 284 | self, |
| 285 | prompt: str, |
| 286 | image_path: Optional[str], |
| 287 | save_path: str, |
| 288 | model: str, |
| 289 | duration: int = 5, |
| 290 | sound: str = "", |
| 291 | video_ratio: str = "16:9", |
| 292 | resolution: Optional[str] = None, |
| 293 | mode: str = "pro", |
| 294 | cfg_scale: float = 0.5, |
| 295 | negative_prompt: str = "", |
| 296 | ) -> str: |
| 297 | """通过可灵模型生成视频""" |
| 298 | logger.info("VideoClient routed to Kling: model=%s", model) |
| 299 | return self.kling_client.generate_video( |
| 300 | prompt=prompt, |
| 301 | image_path=image_path, |
| 302 | save_path=save_path, |
| 303 | model=model, |
| 304 | duration=duration, |
| 305 | sound=sound, |
| 306 | video_ratio=video_ratio, |
| 307 | resolution=resolution, |
| 308 | mode=mode, |
| 309 | cfg_scale=cfg_scale, |
| 310 | negative_prompt=negative_prompt, |
| 311 | ) |
| 312 | |
| 313 | def _generate_seedance( |
| 314 | self, |
| 315 | prompt: str, |
| 316 | image_path: Optional[str], |
| 317 | save_path: str, |
| 318 | model: str, |
| 319 | duration: int = 5, |
| 320 | video_ratio: str = "16:9", |
| 321 | resolution: Optional[str] = None, |
| 322 | seed: Optional[int] = None, |
| 323 | watermark: Optional[bool] = None, |
| 324 | generate_audio: Optional[bool] = None, |
| 325 | ) -> str: |
| 326 | """通过 Seedance 模型生成视频""" |
| 327 | logger.info("VideoClient routed to Seedance: model=%s", model) |
| 328 | return self.seedance_client.generate_video( |
| 329 | prompt=prompt, |
| 330 | image_path=image_path, |
| 331 | save_path=save_path, |
| 332 | model=model, |
| 333 | duration=duration, |
| 334 | ratio=video_ratio, |
| 335 | resolution=self._normalize_seedance_resolution(resolution), |
| 336 | seed=seed, |
| 337 | watermark=watermark, |
| 338 | generate_audio=generate_audio, |
| 339 | ) |
| 340 | |
| 341 | |
| 342 | def _split_csv(value: Optional[str]) -> list[str]: |
| 343 | if not value: |
| 344 | return [] |
| 345 | return [item.strip() for item in value.split(",") if item.strip()] |
| 346 | |
| 347 | |
| 348 | def _str_to_bool(value: Optional[str]) -> Optional[bool]: |
| 349 | if value is None: |
| 350 | return None |
| 351 | normalized = value.strip().lower() |
| 352 | if normalized in {"1", "true", "yes", "on"}: |
| 353 | return True |
| 354 | if normalized in {"0", "false", "no", "off"}: |
| 355 | return False |
| 356 | raise ValueError(f"Invalid boolean value: {value}") |
| 357 | |
| 358 | |
| 359 | def _default_save_path(model: str, generation_mode: str) -> str: |
| 360 | safe_model = "".join(ch if ch.isalnum() or ch in {"-", "_", "."} else "_" for ch in model) |
| 361 | return os.path.join(Config.RESULT_DIR, "video", "test_client", f"{safe_model}_{generation_mode}.mp4") |
| 362 | |
| 363 | |
| 364 | def _build_cli_parser(): |
| 365 | import argparse |
| 366 | |
| 367 | parser = argparse.ArgumentParser(description="Test VideoClient with a selected generation mode and model.") |
| 368 | parser.add_argument("--generation-mode", choices=["first_frame", "start_end_frame", "reference"], default="first_frame") |
| 369 | parser.add_argument("--model", default="wan2.7-i2v", help="Video model id, e.g. wan2.7-i2v / wan2.7-r2v / happyhorse-1.0-r2v") |
| 370 | parser.add_argument("--prompt", default="电影感画面,人物自然移动,镜头稳定推进,不要字幕或水印。") |
| 371 | parser.add_argument("--image", help="First-frame image path for first_frame/start_end_frame, or an extra reference image for reference mode.") |
| 372 | parser.add_argument("--last-image", help="Last-frame image path for start_end_frame mode.") |
| 373 | parser.add_argument("--reference-images", help="Comma-separated reference image paths for reference mode.") |
| 374 | parser.add_argument("--reference-videos", help="Comma-separated reference video paths for reference mode.") |
| 375 | parser.add_argument("--reference-audio", help="Reference audio path for supported reference-to-video models.") |
| 376 | parser.add_argument("--audio", help="Driving audio path for supported image-to-video models.") |
| 377 | parser.add_argument("--first-clip", help="First clip path for supported video continuation models.") |
| 378 | parser.add_argument("--save-path", help="Output mp4 path. Defaults to code/result/video/test_client/<model>_<mode>.mp4") |
| 379 | parser.add_argument("--duration", type=int, default=5) |
| 380 | parser.add_argument("--ratio", default="16:9") |
| 381 | parser.add_argument("--resolution", default="720P") |
| 382 | parser.add_argument("--shot-type", default="multi") |
| 383 | parser.add_argument("--sound", default="") |
| 384 | parser.add_argument("--negative-prompt", default="") |
| 385 | parser.add_argument("--prompt-extend", choices=["true", "false"]) |
| 386 | parser.add_argument("--watermark", choices=["true", "false"]) |
| 387 | parser.add_argument("--seed", type=int) |
| 388 | parser.add_argument("--mode", default="pro", help="Kling mode fallback: std/pro.") |
| 389 | parser.add_argument("--cfg-scale", type=float, default=0.5) |
| 390 | parser.add_argument("--generate-audio", choices=["true", "false"]) |
| 391 | parser.add_argument("--audio-enabled", choices=["true", "false"], help="Pass DashScope audio boolean for supported models.") |
| 392 | return parser |
| 393 | |
| 394 | |
| 395 | def _cli_generate(args) -> str: |
| 396 | reference_image_paths = _split_csv(args.reference_images) |
| 397 | reference_video_paths = _split_csv(args.reference_videos) |
| 398 | image_path = args.image |
| 399 | last_image_path = None |
| 400 | |
| 401 | if args.generation_mode == "first_frame": |
| 402 | if not image_path and not args.first_clip: |
| 403 | raise ValueError("first_frame mode requires --image or --first-clip.") |
| 404 | elif args.generation_mode == "start_end_frame": |
| 405 | if not image_path or not args.last_image: |
| 406 | raise ValueError("start_end_frame mode requires --image and --last-image.") |
| 407 | last_image_path = args.last_image |
| 408 | elif args.generation_mode == "reference": |
| 409 | if image_path: |
| 410 | reference_image_paths.append(image_path) |
| 411 | image_path = None |
| 412 | if not reference_image_paths and not reference_video_paths: |
| 413 | raise ValueError("reference mode requires --reference-images, --reference-videos, or --image.") |
| 414 | |
| 415 | save_path = args.save_path or _default_save_path(args.model, args.generation_mode) |
| 416 | client = VideoClient() |
| 417 | return client.generate_video( |
| 418 | prompt=args.prompt, |
| 419 | image_path=image_path, |
| 420 | save_path=save_path, |
| 421 | model=args.model, |
| 422 | duration=args.duration, |
| 423 | shot_type=args.shot_type, |
| 424 | sound=args.sound, |
| 425 | video_ratio=args.ratio, |
| 426 | resolution=args.resolution, |
| 427 | last_image_path=last_image_path, |
| 428 | first_clip_path=args.first_clip, |
| 429 | reference_image_paths=reference_image_paths or None, |
| 430 | reference_video_paths=reference_video_paths or None, |
| 431 | reference_audio_path=args.reference_audio, |
| 432 | audio_path=args.audio, |
| 433 | negative_prompt=args.negative_prompt or None, |
| 434 | prompt_extend=_str_to_bool(args.prompt_extend), |
| 435 | watermark=_str_to_bool(args.watermark), |
| 436 | seed=args.seed, |
| 437 | mode=args.mode, |
| 438 | cfg_scale=args.cfg_scale, |
| 439 | generate_audio=_str_to_bool(args.generate_audio), |
| 440 | audio=_str_to_bool(args.audio_enabled), |
| 441 | ) |
| 442 | |
| 443 | |
| 444 | if __name__ == "__main__": |
| 445 | logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s") |
| 446 | parser = _build_cli_parser() |
| 447 | cli_args = parser.parse_args() |
| 448 | try: |
| 449 | remote_url = _cli_generate(cli_args) |
| 450 | output_path = cli_args.save_path or _default_save_path(cli_args.model, cli_args.generation_mode) |
| 451 | print("✓ Video generation completed") |
| 452 | print(f" Remote URL: {remote_url}") |
| 453 | print(f" Local file: {os.path.abspath(output_path)}") |
| 454 | except Exception as exc: |
| 455 | print(f"✗ Video generation failed: {exc}", file=sys.stderr) |
| 456 | sys.exit(1) |
| 457 |