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