返回 VideoClaw
video_dashscope.py
根目录 / video-claw / video-claw / backend / models / video_dashscope.py
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
586 lines PYTHON