| 1 | """ |
| 2 | 统一视频生成客户端 |
| 3 | 根据 model 名称自动路由到对应后端: |
| 4 | - wan* → DashscopeVideoClient (DashScope VideoSynthesis) |
| 5 | - kling* → KlingVideoClient (可灵 AI) |
| 6 | """ |
| 7 | |
| 8 | import os |
| 9 | import logging |
| 10 | from typing import Optional |
| 11 | from .config import Config |
| 12 | |
| 13 | try: |
| 14 | from .video_dashscope import DashscopeVideoClient |
| 15 | from .video_kling import KlingVideoClient |
| 16 | from .video_seedance import SeedanceVideoClient |
| 17 | except ImportError: |
| 18 | from video_dashscope import DashscopeVideoClient |
| 19 | from video_kling import KlingVideoClient |
| 20 | from video_seedance import SeedanceVideoClient |
| 21 | |
| 22 | logger = logging.getLogger(__name__) |
| 23 | |
| 24 | |
| 25 | class VideoClient: |
| 26 | """ |
| 27 | 统一视频生成客户端 |
| 28 | 参照 ImageClient 模式,按模型名路由到不同后端 |
| 29 | """ |
| 30 | |
| 31 | def __init__( |
| 32 | self, |
| 33 | dashscope_api_key: Optional[str] = None, |
| 34 | dashscope_base_url: Optional[str] = None, |
| 35 | dashscope_local_proxy: Optional[str] = None, |
| 36 | kling_access_key: Optional[str] = None, |
| 37 | kling_secret_key: Optional[str] = None, |
| 38 | kling_base_url: Optional[str] = None, |
| 39 | kling_local_proxy: Optional[str] = None, |
| 40 | ark_api_key: Optional[str] = None, |
| 41 | ark_base_url: Optional[str] = None, |
| 42 | ark_local_proxy: Optional[str] = None, |
| 43 | ): |
| 44 | self._dashscope_api_key = dashscope_api_key or Config.DASHSCOPE_API_KEY |
| 45 | self._dashscope_base_url = dashscope_base_url or Config.DASHSCOPE_BASE_URL |
| 46 | self._dashscope_local_proxy = dashscope_local_proxy |
| 47 | |
| 48 | self._kling_access_key = kling_access_key or Config.KLING_ACCESS_KEY |
| 49 | self._kling_secret_key = kling_secret_key or Config.KLING_SECRET_KEY |
| 50 | self._kling_base_url = kling_base_url or Config.KLING_BASE_URL |
| 51 | self._kling_local_proxy = kling_local_proxy |
| 52 | |
| 53 | self._ark_api_key = ark_api_key or Config.ARK_API_KEY or os.getenv("ARK_API_KEY") |
| 54 | self._ark_base_url = ark_base_url or Config.ARK_BASE_URL or os.getenv("ARK_BASE_URL") |
| 55 | self._ark_local_proxy = ark_local_proxy |
| 56 | |
| 57 | self._dashscope_client = None |
| 58 | self._kling_client = None |
| 59 | self._seedance_client = None |
| 60 | |
| 61 | @property |
| 62 | def Dashscope_client(self): |
| 63 | """Create DashScope client only when a Wan/HappyHorse model is selected.""" |
| 64 | if self._dashscope_client is None: |
| 65 | self._dashscope_client = DashscopeVideoClient( |
| 66 | api_key=self._dashscope_api_key, |
| 67 | base_url=self._dashscope_base_url, |
| 68 | local_proxy=self._dashscope_local_proxy, |
| 69 | ) |
| 70 | return self._dashscope_client |
| 71 | |
| 72 | @property |
| 73 | def kling_client(self): |
| 74 | """Create Kling client only when a Kling model is selected.""" |
| 75 | if self._kling_client is None: |
| 76 | self._kling_client = KlingVideoClient( |
| 77 | access_key=self._kling_access_key, |
| 78 | secret_key=self._kling_secret_key, |
| 79 | base_url=self._kling_base_url, |
| 80 | local_proxy=self._kling_local_proxy, |
| 81 | ) |
| 82 | return self._kling_client |
| 83 | |
| 84 | @property |
| 85 | def seedance_client(self): |
| 86 | """Create Seedance client only when a Seedance/ARK model is selected.""" |
| 87 | if self._seedance_client is None: |
| 88 | self._seedance_client = SeedanceVideoClient( |
| 89 | api_key=self._ark_api_key, |
| 90 | base_url=self._ark_base_url, |
| 91 | local_proxy=self._ark_local_proxy, |
| 92 | ) |
| 93 | return self._seedance_client |
| 94 | |
| 95 | def generate_video( |
| 96 | self, |
| 97 | prompt: str, |
| 98 | image_path: Optional[str], |
| 99 | save_path: str, |
| 100 | model: str = "wan2.7-i2v", |
| 101 | duration: int = 5, |
| 102 | shot_type: str = "multi", |
| 103 | sound: str = "", |
| 104 | video_ratio: str = "16:9", |
| 105 | resolution: Optional[str] = None, |
| 106 | last_image_path: Optional[str] = None, |
| 107 | first_clip_path: Optional[str] = None, |
| 108 | reference_image_path: Optional[str] = None, |
| 109 | reference_image_paths: Optional[list[str]] = None, |
| 110 | reference_video_paths: Optional[list[str]] = None, |
| 111 | reference_audio_path: Optional[str] = None, |
| 112 | audio_path: Optional[str] = None, |
| 113 | negative_prompt: Optional[str] = None, |
| 114 | prompt_extend: Optional[bool] = None, |
| 115 | watermark: Optional[bool] = None, |
| 116 | seed: Optional[int] = None, |
| 117 | mode: str = "pro", |
| 118 | cfg_scale: float = 0.5, |
| 119 | generate_audio: Optional[bool] = None, |
| 120 | audio: Optional[bool] = None, |
| 121 | ) -> str: |
| 122 | """ |
| 123 | 生成视频 |
| 124 | |
| 125 | Args: |
| 126 | prompt: 视频描述提示词 |
| 127 | image_path: 输入图片本地路径;DashScope wan2.7 视频续写可为空并使用 first_clip_path |
| 128 | save_path: 输出视频保存路径 |
| 129 | model: 模型名,决定使用哪个后端 |
| 130 | duration: 视频时长(秒) |
| 131 | shot_type: 镜头类型 "single" / "multi" |
| 132 | |
| 133 | Returns: |
| 134 | video_url: 远端视频 URL |
| 135 | |
| 136 | Raises: |
| 137 | FileNotFoundError: 输入图片不存在 |
| 138 | RuntimeError: 生成或下载失败 |
| 139 | """ |
| 140 | if not model: |
| 141 | model = "wan2.7-i2v" |
| 142 | |
| 143 | if Config.PRINT_MODEL_INPUT: |
| 144 | print("---- VIDEO GENERATION REQUEST ----") |
| 145 | print(f"Prompt: {prompt}") |
| 146 | if image_path and str(image_path).startswith("data:"): |
| 147 | print(f"Image: [Base64图片]") |
| 148 | else: |
| 149 | print(f"Image: {image_path}") |
| 150 | print(f"Model: {model}") |
| 151 | print(f"Duration: {duration}s") |
| 152 | print(f"Shot Type: {shot_type}") |
| 153 | print(f"Video Ratio: {video_ratio}") |
| 154 | if resolution: |
| 155 | print(f"Resolution: {resolution}") |
| 156 | if last_image_path: |
| 157 | print(f"Last Image: {last_image_path}") |
| 158 | if first_clip_path: |
| 159 | print(f"First Clip: {first_clip_path}") |
| 160 | if reference_image_path: |
| 161 | print(f"Reference Image: {reference_image_path}") |
| 162 | if reference_image_paths: |
| 163 | print(f"Reference Images: {reference_image_paths}") |
| 164 | if reference_video_paths: |
| 165 | print(f"Reference Videos: {reference_video_paths}") |
| 166 | if reference_audio_path: |
| 167 | print(f"Reference Audio: {reference_audio_path}") |
| 168 | if audio_path: |
| 169 | print(f"Audio: {audio_path}") |
| 170 | if negative_prompt: |
| 171 | print(f"Negative Prompt: {negative_prompt}") |
| 172 | if sound: |
| 173 | print(f"Sound: {sound}") |
| 174 | print(f"Save: {save_path}") |
| 175 | print("-" * 30) |
| 176 | |
| 177 | model_lower = model.lower() |
| 178 | |
| 179 | if "kling" in model_lower: |
| 180 | return self._generate_kling( |
| 181 | prompt, |
| 182 | image_path, |
| 183 | save_path, |
| 184 | model, |
| 185 | duration, |
| 186 | sound, |
| 187 | mode, |
| 188 | cfg_scale, |
| 189 | negative_prompt or "", |
| 190 | video_ratio, |
| 191 | ) |
| 192 | elif "seedance" in model_lower: |
| 193 | return self._generate_seedance( |
| 194 | prompt, |
| 195 | image_path, |
| 196 | save_path, |
| 197 | model, |
| 198 | duration, |
| 199 | video_ratio, |
| 200 | resolution, |
| 201 | seed, |
| 202 | watermark, |
| 203 | generate_audio, |
| 204 | ) |
| 205 | elif "wan" in model_lower or "happyhorse" in model_lower: |
| 206 | return self._generate_wan( |
| 207 | prompt, |
| 208 | image_path, |
| 209 | save_path, |
| 210 | model, |
| 211 | duration, |
| 212 | shot_type, |
| 213 | video_ratio, |
| 214 | last_image_path, |
| 215 | first_clip_path, |
| 216 | reference_image_path, |
| 217 | reference_image_paths, |
| 218 | reference_video_paths, |
| 219 | reference_audio_path, |
| 220 | audio_path, |
| 221 | negative_prompt, |
| 222 | resolution, |
| 223 | prompt_extend, |
| 224 | watermark if watermark is not None else False, |
| 225 | seed, |
| 226 | audio, |
| 227 | ) |
| 228 | else: |
| 229 | raise ValueError(f"未知的视频生成模型: {model}") |
| 230 | |
| 231 | def _generate_wan( |
| 232 | self, |
| 233 | prompt: str, |
| 234 | image_path: Optional[str], |
| 235 | save_path: str, |
| 236 | model: str, |
| 237 | duration: int, |
| 238 | shot_type: str, |
| 239 | video_ratio: str, |
| 240 | last_image_path: Optional[str], |
| 241 | first_clip_path: Optional[str], |
| 242 | reference_image_path: Optional[str], |
| 243 | reference_image_paths: Optional[list[str]], |
| 244 | reference_video_paths: Optional[list[str]], |
| 245 | reference_audio_path: Optional[str], |
| 246 | audio_path: Optional[str], |
| 247 | negative_prompt: Optional[str], |
| 248 | resolution: Optional[str], |
| 249 | prompt_extend: Optional[bool], |
| 250 | watermark: bool, |
| 251 | seed: Optional[int], |
| 252 | audio: Optional[bool], |
| 253 | ) -> str: |
| 254 | """通过万象模型生成视频""" |
| 255 | logger.info(f"VideoClient: 路由至万象 model={model}") |
| 256 | return self.Dashscope_client.generate_video( |
| 257 | prompt=prompt, |
| 258 | image_path=image_path, |
| 259 | save_path=save_path, |
| 260 | model=model, |
| 261 | duration=duration, |
| 262 | shot_type=shot_type, |
| 263 | video_ratio=video_ratio, |
| 264 | last_image_path=last_image_path, |
| 265 | first_clip_path=first_clip_path, |
| 266 | reference_image_path=reference_image_path, |
| 267 | reference_image_paths=reference_image_paths, |
| 268 | reference_video_paths=reference_video_paths, |
| 269 | reference_audio_path=reference_audio_path, |
| 270 | audio_path=audio_path, |
| 271 | negative_prompt=negative_prompt, |
| 272 | resolution=resolution, |
| 273 | prompt_extend=prompt_extend, |
| 274 | watermark=watermark, |
| 275 | seed=seed, |
| 276 | audio=audio, |
| 277 | ) |
| 278 | |
| 279 | def _generate_kling( |
| 280 | self, |
| 281 | prompt: str, |
| 282 | image_path: Optional[str], |
| 283 | save_path: str, |
| 284 | model: str, |
| 285 | duration: int = 5, |
| 286 | sound: str = "", |
| 287 | mode: str = "pro", |
| 288 | cfg_scale: float = 0.5, |
| 289 | negative_prompt: str = "", |
| 290 | video_ratio: str = "16:9", |
| 291 | ) -> str: |
| 292 | """通过可灵模型生成视频""" |
| 293 | logger.info(f"VideoClient: 路由至可灵 model={model}") |
| 294 | return self.kling_client.generate_video( |
| 295 | prompt=prompt, |
| 296 | image_path=image_path, |
| 297 | save_path=save_path, |
| 298 | model=model, |
| 299 | duration=duration, |
| 300 | sound=sound, |
| 301 | mode=mode, |
| 302 | cfg_scale=cfg_scale, |
| 303 | negative_prompt=negative_prompt, |
| 304 | aspect_ratio=video_ratio, |
| 305 | ) |
| 306 | |
| 307 | def _generate_seedance( |
| 308 | self, |
| 309 | prompt: str, |
| 310 | image_path: Optional[str], |
| 311 | save_path: str, |
| 312 | model: str, |
| 313 | duration: int = 5, |
| 314 | video_ratio: str = "16:9", |
| 315 | resolution: Optional[str] = None, |
| 316 | seed: Optional[int] = None, |
| 317 | watermark: Optional[bool] = None, |
| 318 | generate_audio: Optional[bool] = None, |
| 319 | ) -> str: |
| 320 | """通过 Seedance 模型生成视频""" |
| 321 | logger.info(f"VideoClient: 路由至 Seedance model={model}") |
| 322 | return self.seedance_client.generate_video( |
| 323 | prompt=prompt, |
| 324 | image_path=image_path, |
| 325 | save_path=save_path, |
| 326 | model=model, |
| 327 | duration=duration, |
| 328 | ratio=video_ratio, |
| 329 | resolution=resolution or "720p", |
| 330 | seed=seed, |
| 331 | watermark=watermark, |
| 332 | generate_audio=generate_audio, |
| 333 | ) |
| 334 |