| 1 | """ |
| 2 | 通义万象(Wan)视频生成客户端 |
| 3 | 基于 DashScope SDK (dashscope.VideoSynthesis) |
| 4 | 支持 wan2.7-i2v, wan2.6-i2v-flash 等模型的图生视频功能 |
| 5 | """ |
| 6 | |
| 7 | import os |
| 8 | import sys |
| 9 | |
| 10 | models_dir = os.path.dirname(os.path.abspath(__file__)) |
| 11 | backend_dir = os.path.dirname(models_dir) |
| 12 | if backend_dir not in sys.path: |
| 13 | sys.path.insert(0, backend_dir) |
| 14 | |
| 15 | import logging |
| 16 | import time |
| 17 | from typing import Optional |
| 18 | from http import HTTPStatus |
| 19 | |
| 20 | try: |
| 21 | import dashscope |
| 22 | from dashscope import VideoSynthesis |
| 23 | except ImportError: |
| 24 | dashscope = None |
| 25 | VideoSynthesis = None |
| 26 | import requests |
| 27 | from requests import exceptions as requests_exceptions |
| 28 | from config import Config |
| 29 | |
| 30 | logger = logging.getLogger(__name__) |
| 31 | |
| 32 | |
| 33 | class DashscopeVideoClient: |
| 34 | """ |
| 35 | 阿里云通义万象视频生成客户端 |
| 36 | 使用 dashscope SDK 的 VideoSynthesis 接口 |
| 37 | """ |
| 38 | |
| 39 | def __init__( |
| 40 | self, |
| 41 | api_key: Optional[str] = None, |
| 42 | base_url: Optional[str] = None, |
| 43 | ) -> None: |
| 44 | self.api_key = api_key or Config.DASHSCOPE_API_KEY |
| 45 | self.base_url = base_url or Config.DASHSCOPE_BASE_URL |
| 46 | |
| 47 | if dashscope and self.api_key: |
| 48 | dashscope.api_key = self.api_key |
| 49 | if dashscope and self.base_url: |
| 50 | dashscope.base_http_api_url = self.base_url |
| 51 | |
| 52 | _RETRYABLE_EXCEPTIONS = ( |
| 53 | requests_exceptions.ConnectionError, |
| 54 | requests_exceptions.Timeout, |
| 55 | requests_exceptions.SSLError, |
| 56 | requests_exceptions.ChunkedEncodingError, |
| 57 | requests_exceptions.ContentDecodingError, |
| 58 | TimeoutError, |
| 59 | ConnectionError, |
| 60 | ) |
| 61 | |
| 62 | def _with_network_retry(self, action_name: str, func, max_attempts: int = 5, base_delay: float = 3.0): |
| 63 | """Retry transient network failures without hiding provider-side task failures.""" |
| 64 | last_error = None |
| 65 | for attempt in range(1, max_attempts + 1): |
| 66 | try: |
| 67 | return func() |
| 68 | except self._RETRYABLE_EXCEPTIONS as exc: |
| 69 | last_error = exc |
| 70 | except Exception as exc: |
| 71 | if not self._is_retryable_error(exc): |
| 72 | raise |
| 73 | last_error = exc |
| 74 | |
| 75 | if attempt >= max_attempts: |
| 76 | break |
| 77 | delay = min(base_delay * attempt, 20) |
| 78 | logger.warning( |
| 79 | "DashscopeVideoClient: %s network error, retrying %s/%s in %.1fs: %s", |
| 80 | action_name, |
| 81 | attempt, |
| 82 | max_attempts, |
| 83 | delay, |
| 84 | last_error, |
| 85 | ) |
| 86 | time.sleep(delay) |
| 87 | |
| 88 | raise RuntimeError( |
| 89 | f"DashScope {action_name} failed after {max_attempts} attempts due to network error: {last_error}" |
| 90 | ) from last_error |
| 91 | |
| 92 | def _is_retryable_error(self, exc: Exception) -> bool: |
| 93 | message = str(exc).lower() |
| 94 | retry_markers = ( |
| 95 | "ssleoferror", |
| 96 | "unexpected_eof", |
| 97 | "eof occurred in violation of protocol", |
| 98 | "connection reset", |
| 99 | "connection aborted", |
| 100 | "remote disconnected", |
| 101 | "max retries exceeded", |
| 102 | "read timed out", |
| 103 | "connect timed out", |
| 104 | "temporarily unavailable", |
| 105 | ) |
| 106 | return any(marker in message for marker in retry_markers) |
| 107 | |
| 108 | def generate_video( |
| 109 | self, |
| 110 | prompt: str, |
| 111 | image_path: Optional[str], |
| 112 | save_path: str, |
| 113 | model: str = "wan2.7-i2v", |
| 114 | duration: int = 10, |
| 115 | shot_type: str = "multi", |
| 116 | video_ratio: Optional[str] = None, |
| 117 | last_image_path: Optional[str] = None, |
| 118 | first_clip_path: Optional[str] = None, |
| 119 | reference_image_path: Optional[str] = None, |
| 120 | reference_image_paths: Optional[list[str]] = None, |
| 121 | reference_video_paths: Optional[list[str]] = None, |
| 122 | reference_audio_path: Optional[str] = None, |
| 123 | audio_path: Optional[str] = None, |
| 124 | negative_prompt: Optional[str] = None, |
| 125 | resolution: Optional[str] = None, |
| 126 | prompt_extend: Optional[bool] = None, |
| 127 | watermark: bool = False, |
| 128 | seed: Optional[int] = None, |
| 129 | audio: Optional[bool] = None, |
| 130 | ) -> str: |
| 131 | """ |
| 132 | 图生视频:提交任务 → 等待完成 → 下载到本地 |
| 133 | |
| 134 | Args: |
| 135 | prompt: 视频描述提示词 |
| 136 | image_path: 输入首帧图片本地路径 |
| 137 | save_path: 输出视频保存路径 |
| 138 | model: 万象视频模型名 |
| 139 | duration: 视频时长(秒) |
| 140 | shot_type: 镜头类型,"single" 或 "multi" |
| 141 | video_ratio: 输出画幅比例,如 9:16 / 16:9 |
| 142 | last_image_path: 可选尾帧图片本地路径(wan2.7) |
| 143 | first_clip_path: 可选首段视频本地路径(wan2.7 视频续写) |
| 144 | reference_image_path: 可选参考图片路径(videoedit) |
| 145 | reference_image_paths: 可选参考图片列表(r2v) |
| 146 | reference_video_paths: 可选参考视频列表(r2v) |
| 147 | reference_audio_path: 可选参考音频/音色路径(r2v) |
| 148 | audio_path: 可选驱动音频本地路径(wan2.7) |
| 149 | |
| 150 | Returns: |
| 151 | video_url: 远端视频 URL |
| 152 | |
| 153 | Raises: |
| 154 | FileNotFoundError: 输入图片不存在 |
| 155 | RuntimeError: API 调用或下载失败 |
| 156 | """ |
| 157 | if VideoSynthesis is None: |
| 158 | raise RuntimeError("dashscope package not installed. Run: pip install dashscope") |
| 159 | |
| 160 | if image_path and not os.path.exists(image_path): |
| 161 | raise FileNotFoundError(f"输入图片不存在: {image_path}") |
| 162 | if last_image_path and not os.path.exists(last_image_path): |
| 163 | raise FileNotFoundError(f"尾帧图片不存在: {last_image_path}") |
| 164 | if first_clip_path and not os.path.exists(first_clip_path): |
| 165 | raise FileNotFoundError(f"输入视频片段不存在: {first_clip_path}") |
| 166 | if reference_image_path and not os.path.exists(reference_image_path): |
| 167 | raise FileNotFoundError(f"参考图片不存在: {reference_image_path}") |
| 168 | for ref_image_path in reference_image_paths or []: |
| 169 | if ref_image_path and not os.path.exists(ref_image_path): |
| 170 | raise FileNotFoundError(f"参考图片不存在: {ref_image_path}") |
| 171 | for ref_video_path in reference_video_paths or []: |
| 172 | if ref_video_path and not os.path.exists(ref_video_path): |
| 173 | raise FileNotFoundError(f"参考视频不存在: {ref_video_path}") |
| 174 | if reference_audio_path and not os.path.exists(reference_audio_path): |
| 175 | raise FileNotFoundError(f"参考音频不存在: {reference_audio_path}") |
| 176 | if audio_path and not os.path.exists(audio_path): |
| 177 | raise FileNotFoundError(f"驱动音频不存在: {audio_path}") |
| 178 | |
| 179 | logger.info(f"DashscopeVideoClient: model={model}, prompt={prompt[:60]}...") |
| 180 | |
| 181 | if self._is_reference_to_video_model(model): |
| 182 | media = self._build_reference_to_video_media( |
| 183 | image_path=image_path, |
| 184 | reference_image_path=reference_image_path, |
| 185 | reference_image_paths=reference_image_paths, |
| 186 | reference_video_paths=reference_video_paths, |
| 187 | reference_audio_path=None if "happyhorse" in model.lower() else reference_audio_path, |
| 188 | ) |
| 189 | if not media: |
| 190 | raise ValueError("DashScope reference-to-video models require at least one reference_image or reference_video input.") |
| 191 | |
| 192 | call_kwargs = { |
| 193 | "api_key": self.api_key, |
| 194 | "model": model, |
| 195 | "prompt": prompt, |
| 196 | "media": media, |
| 197 | "duration": duration, |
| 198 | "watermark": watermark, |
| 199 | } |
| 200 | if audio is not None: |
| 201 | call_kwargs["audio"] = audio |
| 202 | if negative_prompt: |
| 203 | call_kwargs["negative_prompt"] = negative_prompt |
| 204 | if resolution: |
| 205 | call_kwargs["resolution"] = resolution |
| 206 | if video_ratio: |
| 207 | call_kwargs["ratio"] = video_ratio |
| 208 | if prompt_extend is not None: |
| 209 | call_kwargs["prompt_extend"] = prompt_extend |
| 210 | if seed is not None: |
| 211 | call_kwargs["seed"] = seed |
| 212 | |
| 213 | rsp = self._with_network_retry( |
| 214 | "submit task", |
| 215 | lambda: VideoSynthesis.call(**call_kwargs), |
| 216 | ) |
| 217 | elif self._is_video_edit_model(model): |
| 218 | media = self._build_video_edit_media( |
| 219 | video_path=first_clip_path, |
| 220 | reference_image_path=reference_image_path or last_image_path or image_path, |
| 221 | ) |
| 222 | if not media: |
| 223 | raise ValueError("DashScope video edit models require video input and may use reference_image input.") |
| 224 | |
| 225 | call_kwargs = { |
| 226 | "api_key": self.api_key, |
| 227 | "model": model, |
| 228 | "prompt": prompt, |
| 229 | "media": media, |
| 230 | "duration": duration, |
| 231 | "watermark": watermark, |
| 232 | } |
| 233 | if negative_prompt: |
| 234 | call_kwargs["negative_prompt"] = negative_prompt |
| 235 | if resolution: |
| 236 | call_kwargs["resolution"] = resolution |
| 237 | if video_ratio: |
| 238 | call_kwargs["ratio"] = video_ratio |
| 239 | if prompt_extend is not None: |
| 240 | call_kwargs["prompt_extend"] = prompt_extend |
| 241 | if seed is not None: |
| 242 | call_kwargs["seed"] = seed |
| 243 | |
| 244 | rsp = self._with_network_retry( |
| 245 | "submit task", |
| 246 | lambda: VideoSynthesis.call(**call_kwargs), |
| 247 | ) |
| 248 | elif model.startswith("wan2.7") or "happyhorse" in model: |
| 249 | # wan2.7 series use the new API format with 'media' |
| 250 | media = self._build_media( |
| 251 | image_path=image_path, |
| 252 | last_image_path=last_image_path, |
| 253 | first_clip_path=first_clip_path, |
| 254 | audio_path=audio_path, |
| 255 | ) |
| 256 | if not media: |
| 257 | raise ValueError("DashScope wan2.7 video generation requires first_frame or first_clip input.") |
| 258 | self._validate_media_combination(media) |
| 259 | |
| 260 | call_kwargs = { |
| 261 | "api_key": self.api_key, |
| 262 | "model": model, |
| 263 | "prompt": prompt, |
| 264 | "media": media, |
| 265 | "duration": duration, |
| 266 | "watermark": watermark, |
| 267 | } |
| 268 | if negative_prompt: |
| 269 | call_kwargs["negative_prompt"] = negative_prompt |
| 270 | if resolution: |
| 271 | call_kwargs["resolution"] = resolution |
| 272 | if video_ratio: |
| 273 | call_kwargs["ratio"] = video_ratio |
| 274 | if prompt_extend is not None: |
| 275 | call_kwargs["prompt_extend"] = prompt_extend |
| 276 | if seed is not None: |
| 277 | call_kwargs["seed"] = seed |
| 278 | |
| 279 | rsp = self._with_network_retry( |
| 280 | "submit task", |
| 281 | lambda: VideoSynthesis.call(**call_kwargs), |
| 282 | ) |
| 283 | else: |
| 284 | # Older models (wan2.1, wan2.6 etc.) use 'img_url' and 'shot_type' |
| 285 | if not image_path: |
| 286 | raise ValueError("DashScope legacy video models require image_path.") |
| 287 | |
| 288 | call_kwargs = { |
| 289 | "api_key": self.api_key, |
| 290 | "model": model, |
| 291 | "prompt": prompt, |
| 292 | "img_url": self._to_media_url(image_path), |
| 293 | "duration": duration, |
| 294 | "shot_type": shot_type, |
| 295 | } |
| 296 | if negative_prompt: |
| 297 | call_kwargs["negative_prompt"] = negative_prompt |
| 298 | if resolution: |
| 299 | call_kwargs["resolution"] = resolution |
| 300 | if video_ratio: |
| 301 | call_kwargs["ratio"] = video_ratio |
| 302 | if prompt_extend is not None: |
| 303 | call_kwargs["prompt_extend"] = prompt_extend |
| 304 | if watermark is not None: |
| 305 | call_kwargs["watermark"] = watermark |
| 306 | if seed is not None: |
| 307 | call_kwargs["seed"] = seed |
| 308 | |
| 309 | rsp = self._with_network_retry( |
| 310 | "submit task", |
| 311 | lambda: VideoSynthesis.call(**call_kwargs), |
| 312 | ) |
| 313 | |
| 314 | if rsp.status_code != HTTPStatus.OK: |
| 315 | raise RuntimeError( |
| 316 | f"万象视频 API 错误: status={rsp.status_code}, " |
| 317 | f"code={rsp.code}, message={rsp.message}" |
| 318 | ) |
| 319 | |
| 320 | video_url = self._extract_video_url(rsp) |
| 321 | if not video_url: |
| 322 | task_id = self._extract_task_id(rsp) |
| 323 | task_status = self._extract_task_status(rsp) |
| 324 | if not task_id: |
| 325 | raise RuntimeError( |
| 326 | "万象视频 API 未返回 video_url 或 task_id,无法查询结果: " |
| 327 | f"status={rsp.status_code}, code={rsp.code}, message={rsp.message}, " |
| 328 | f"task_status={task_status}" |
| 329 | ) |
| 330 | |
| 331 | logger.info(f"DashscopeVideoClient: 任务已提交 task_id={task_id}, status={task_status}; 等待生成完成...") |
| 332 | rsp = self._with_network_retry( |
| 333 | f"wait task {task_id}", |
| 334 | lambda: VideoSynthesis.wait(task=rsp, api_key=self.api_key), |
| 335 | max_attempts=8, |
| 336 | base_delay=5.0, |
| 337 | ) |
| 338 | if rsp.status_code != HTTPStatus.OK: |
| 339 | raise RuntimeError( |
| 340 | f"万象视频任务查询失败: status={rsp.status_code}, " |
| 341 | f"code={rsp.code}, message={rsp.message}, task_id={task_id}" |
| 342 | ) |
| 343 | |
| 344 | video_url = self._extract_video_url(rsp) |
| 345 | task_status = self._extract_task_status(rsp) |
| 346 | if not video_url: |
| 347 | raise RuntimeError( |
| 348 | "万象视频任务完成后仍未返回 video_url: " |
| 349 | f"code={rsp.code}, message={rsp.message}, task_id={task_id}, task_status={task_status}, " |
| 350 | f"output={self._safe_output_repr(rsp)}" |
| 351 | ) |
| 352 | |
| 353 | logger.info(f"DashscopeVideoClient: 视频生成成功: {video_url}") |
| 354 | |
| 355 | # 确保输出目录存在 |
| 356 | os.makedirs(os.path.dirname(save_path), exist_ok=True) |
| 357 | |
| 358 | # 下载视频 |
| 359 | resp = self._with_network_retry( |
| 360 | "download video", |
| 361 | lambda: requests.get( |
| 362 | video_url, |
| 363 | stream=True, |
| 364 | timeout=120, |
| 365 | proxies=Config.requests_proxies("dashscope"), |
| 366 | ), |
| 367 | max_attempts=5, |
| 368 | base_delay=3.0, |
| 369 | ) |
| 370 | if resp.status_code != 200: |
| 371 | raise RuntimeError(f"视频下载失败: HTTP {resp.status_code}") |
| 372 | |
| 373 | with open(save_path, 'wb') as f: |
| 374 | for chunk in resp.iter_content(chunk_size=8192): |
| 375 | if chunk: |
| 376 | f.write(chunk) |
| 377 | |
| 378 | logger.info(f"DashscopeVideoClient: 视频已保存: {save_path}") |
| 379 | return video_url |
| 380 | |
| 381 | def _is_video_edit_model(self, model: str) -> bool: |
| 382 | """Return True for DashScope video-edit model IDs.""" |
| 383 | model_lower = model.lower() |
| 384 | return "videoedit" in model_lower or "video-edit" in model_lower |
| 385 | |
| 386 | def _is_reference_to_video_model(self, model: str) -> bool: |
| 387 | """Return True for DashScope reference-to-video model IDs.""" |
| 388 | return "r2v" in model.lower() |
| 389 | |
| 390 | def _extract_video_url(self, rsp) -> Optional[str]: |
| 391 | """Extract video_url from DashScope SDK response variants.""" |
| 392 | output = getattr(rsp, "output", None) |
| 393 | if output is None: |
| 394 | return None |
| 395 | if isinstance(output, dict): |
| 396 | return output.get("video_url") |
| 397 | return getattr(output, "video_url", None) |
| 398 | |
| 399 | def _extract_task_id(self, rsp) -> Optional[str]: |
| 400 | """Extract async task_id from DashScope SDK response variants.""" |
| 401 | output = getattr(rsp, "output", None) |
| 402 | if output is None: |
| 403 | return None |
| 404 | if isinstance(output, dict): |
| 405 | return output.get("task_id") |
| 406 | return getattr(output, "task_id", None) |
| 407 | |
| 408 | def _extract_task_status(self, rsp) -> Optional[str]: |
| 409 | """Extract async task status from DashScope SDK response variants.""" |
| 410 | output = getattr(rsp, "output", None) |
| 411 | if output is None: |
| 412 | return None |
| 413 | if isinstance(output, dict): |
| 414 | return output.get("task_status") |
| 415 | return getattr(output, "task_status", None) |
| 416 | |
| 417 | def _safe_output_repr(self, rsp) -> str: |
| 418 | """Best-effort output representation for provider-side task failures.""" |
| 419 | output = getattr(rsp, "output", None) |
| 420 | try: |
| 421 | if isinstance(output, dict): |
| 422 | return str(output) |
| 423 | if hasattr(output, "__dict__"): |
| 424 | return str(output.__dict__) |
| 425 | return str(output) |
| 426 | except Exception: |
| 427 | return "<unprintable output>" |
| 428 | |
| 429 | def _build_media( |
| 430 | self, |
| 431 | image_path: Optional[str], |
| 432 | last_image_path: Optional[str], |
| 433 | first_clip_path: Optional[str], |
| 434 | audio_path: Optional[str], |
| 435 | ) -> list[dict[str, str]]: |
| 436 | """Build DashScope wan2.7 media array using official media types.""" |
| 437 | media = [] |
| 438 | if first_clip_path: |
| 439 | media.append({"type": "first_clip", "url": self._to_media_url(first_clip_path)}) |
| 440 | elif image_path: |
| 441 | media.append({"type": "first_frame", "url": self._to_media_url(image_path)}) |
| 442 | |
| 443 | if last_image_path: |
| 444 | media.append({"type": "last_frame", "url": self._to_media_url(last_image_path)}) |
| 445 | if audio_path: |
| 446 | media.append({"type": "driving_audio", "url": self._to_media_url(audio_path)}) |
| 447 | return media |
| 448 | |
| 449 | def _build_video_edit_media( |
| 450 | self, |
| 451 | video_path: Optional[str], |
| 452 | reference_image_path: Optional[str], |
| 453 | ) -> list[dict[str, str]]: |
| 454 | """Build DashScope video-edit media array using official media types.""" |
| 455 | media = [] |
| 456 | if video_path: |
| 457 | media.append({"type": "video", "url": self._to_media_url(video_path)}) |
| 458 | if reference_image_path: |
| 459 | media.append({"type": "reference_image", "url": self._to_media_url(reference_image_path)}) |
| 460 | return media |
| 461 | |
| 462 | def _build_reference_to_video_media( |
| 463 | self, |
| 464 | image_path: Optional[str], |
| 465 | reference_image_path: Optional[str], |
| 466 | reference_image_paths: Optional[list[str]], |
| 467 | reference_video_paths: Optional[list[str]], |
| 468 | reference_audio_path: Optional[str], |
| 469 | ) -> list[dict[str, str]]: |
| 470 | """Build DashScope r2v media array using reference_image/reference_video items.""" |
| 471 | media = [] |
| 472 | image_refs = [] |
| 473 | if reference_image_paths: |
| 474 | image_refs.extend(reference_image_paths) |
| 475 | if reference_image_path: |
| 476 | image_refs.append(reference_image_path) |
| 477 | if image_path: |
| 478 | image_refs.append(image_path) |
| 479 | |
| 480 | seen = set() |
| 481 | for index, ref_path in enumerate(image_refs): |
| 482 | if not ref_path or ref_path in seen: |
| 483 | continue |
| 484 | seen.add(ref_path) |
| 485 | item = {"type": "reference_image", "url": self._to_media_url(ref_path)} |
| 486 | if index == 0 and reference_audio_path: |
| 487 | item["reference_voice"] = self._to_media_url(reference_audio_path) |
| 488 | media.append(item) |
| 489 | |
| 490 | for ref_video_path in reference_video_paths or []: |
| 491 | if ref_video_path: |
| 492 | media.append({"type": "reference_video", "url": self._to_media_url(ref_video_path)}) |
| 493 | |
| 494 | return media |
| 495 | |
| 496 | def _validate_media_combination(self, media: list[dict[str, str]]) -> None: |
| 497 | """Validate combinations documented by DashScope wan2.7 i2v.""" |
| 498 | media_types = {item["type"] for item in media} |
| 499 | allowed = [ |
| 500 | {"first_frame"}, |
| 501 | {"first_frame", "driving_audio"}, |
| 502 | {"first_frame", "last_frame"}, |
| 503 | {"first_frame", "last_frame", "driving_audio"}, |
| 504 | {"first_clip"}, |
| 505 | {"first_clip", "last_frame"}, |
| 506 | ] |
| 507 | if media_types not in allowed: |
| 508 | raise ValueError( |
| 509 | "Invalid DashScope media combination: " |
| 510 | f"{'+'.join(sorted(media_types))}. " |
| 511 | "Allowed: first_frame, first_frame+driving_audio, first_frame+last_frame, " |
| 512 | "first_frame+last_frame+driving_audio, first_clip, first_clip+last_frame." |
| 513 | ) |
| 514 | |
| 515 | def _to_media_url(self, path_or_url: str) -> str: |
| 516 | """Convert a local path to file:// while preserving URL/data/OSS inputs.""" |
| 517 | if path_or_url.startswith(("http://", "https://", "file://", "oss://", "data:")): |
| 518 | return path_or_url |
| 519 | return f"file://{os.path.abspath(path_or_url)}" |
| 520 | |
| 521 | |
| 522 | if __name__ == "__main__": |
| 523 | import sys |
| 524 | import time |
| 525 | sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) |
| 526 | from config import Config |
| 527 | |
| 528 | logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") |
| 529 | |
| 530 | # ── 测试参数(按需修改) ── |
| 531 | IMAGE_PATH = "code/result/image/test_avail/test_input_human.jpg" |
| 532 | OUTPUT_DIR = "code/result/video/test_avail" |
| 533 | PROMPT = "女人把报表交给男人,男人看清楚报表上的数据,露出满意的微笑,办公室背景,写实风格,高清细节。背景音乐:轻快的电子乐,节奏感强,适合办公环境。" |
| 534 | # MODELS = ["wan2.7-i2v", "wan2.6-i2v-flash", "happyhorse-1.0-i2v"] |
| 535 | MODELS = ["happyhorse-1.0-i2v"] |
| 536 | DURATION = 5 # 5 / 10 |
| 537 | SHOT_TYPE = "multi" # single / multi |
| 538 | |
| 539 | print("=== Dashscope 视频客户端可用性测试 ===") |
| 540 | ak = Config.DASHSCOPE_API_KEY |
| 541 | base_url = Config.DASHSCOPE_BASE_URL |
| 542 | if not ak: |
| 543 | print("✗ DASHSCOPE_API_KEY 未设置,请检查 config.yaml 配置") |
| 544 | sys.exit(1) |
| 545 | |
| 546 | if not os.path.exists(IMAGE_PATH): |
| 547 | print(f"✗ 输入图片不存在: {IMAGE_PATH}") |
| 548 | sys.exit(1) |
| 549 | |
| 550 | for model in MODELS: |
| 551 | output_path = os.path.join(OUTPUT_DIR, f"{model}.mp4") |
| 552 | print(f"\n测试模型: {model}") |
| 553 | print(f" API Key : {ak[:6]}***{ak[-4:]}") |
| 554 | print(f" Base URL : {base_url}") |
| 555 | print(f" 输入图片 : {IMAGE_PATH}") |
| 556 | print(f" 输出路径 : {output_path}") |
| 557 | print(f" 模型 : {model}") |
| 558 | print(f" 时长 : {DURATION}s") |
| 559 | print(f" 镜头类型 : {SHOT_TYPE}") |
| 560 | if PROMPT: |
| 561 | print(f" 提示词 : {PROMPT[:80]}") |
| 562 | print("-" * 40) |
| 563 | |
| 564 | try: |
| 565 | client = DashscopeVideoClient(api_key=ak, base_url=base_url) |
| 566 | print("✓ 客户端初始化成功") |
| 567 | |
| 568 | start = time.time() |
| 569 | video_url = client.generate_video( |
| 570 | prompt=PROMPT, |
| 571 | image_path=IMAGE_PATH, |
| 572 | save_path=output_path, |
| 573 | model=model, |
| 574 | duration=DURATION, |
| 575 | shot_type=SHOT_TYPE, |
| 576 | ) |
| 577 | elapsed = time.time() - start |
| 578 | |
| 579 | print(f"✓ 视频生成完成!耗时 {elapsed:.1f}s") |
| 580 | print(f" 远端 URL : {video_url}") |
| 581 | print(f" 本地文件 : {os.path.abspath(output_path)}") |
| 582 | print(f" 文件大小 : {os.path.getsize(output_path) / 1024 / 1024:.2f} MB") |
| 583 | except Exception as e: |
| 584 | print(f"✗ 失败: {e}") |
| 585 | sys.exit(1) |
| 586 |