项目文件夹

文件
wehub-resource-sync 690333661e
Deploy Documentation / deploy (push) Failing after 1s
chore: import upstream snapshot with attribution
2026-07-13 12:19:21 +08:00

634 行
25 KiB
Python

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
"""
通义万象(Wan)视频生成客户端
基于 DashScope SDK (dashscope.VideoSynthesis)
支持 wan2.7-i2v, wan2.6-i2v-flash 等模型的图生视频功能
"""
import os
import logging
import time
import threading
from contextlib import contextmanager
from typing import Optional
from http import HTTPStatus
try:
import dashscope
from dashscope import VideoSynthesis
except ImportError:
dashscope = None
VideoSynthesis = None
import requests
from requests import exceptions as requests_exceptions
logger = logging.getLogger(__name__)
class DashscopeVideoClient:
"""
_proxy_env_lock = threading.Lock()
阿里云通义万象视频生成客户端
使用 dashscope SDK 的 VideoSynthesis 接口
"""
def __init__(
self,
api_key: Optional[str] = None,
base_url: Optional[str] = None,
local_proxy: Optional[str] = None,
) -> None:
self.api_key = api_key or os.getenv("DASHSCOPE_API_KEY")
self.base_url = base_url or os.getenv("DASHSCOPE_BASE_URL")
self.local_proxy = local_proxy
if dashscope and self.api_key:
dashscope.api_key = self.api_key
if dashscope and self.base_url:
dashscope.base_http_api_url = self.base_url
@contextmanager
def _proxy_env(self):
if not self.local_proxy:
yield
return
with self._proxy_env_lock:
keys = ("HTTP_PROXY", "HTTPS_PROXY", "http_proxy", "https_proxy")
old_values = {key: os.environ.get(key) for key in keys}
try:
for key in keys:
os.environ[key] = self.local_proxy
yield
finally:
for key, value in old_values.items():
if value is None:
os.environ.pop(key, None)
else:
os.environ[key] = value
_RETRYABLE_EXCEPTIONS = (
requests_exceptions.ConnectionError,
requests_exceptions.Timeout,
requests_exceptions.SSLError,
requests_exceptions.ChunkedEncodingError,
requests_exceptions.ContentDecodingError,
TimeoutError,
ConnectionError,
)
def _with_network_retry(self, action_name: str, func, max_attempts: int = 5, base_delay: float = 3.0):
"""Retry transient network failures without hiding provider-side task failures."""
last_error = None
for attempt in range(1, max_attempts + 1):
try:
with self._proxy_env():
return func()
except self._RETRYABLE_EXCEPTIONS as exc:
last_error = exc
except Exception as exc:
if not self._is_retryable_error(exc):
raise
last_error = exc
if attempt >= max_attempts:
break
delay = min(base_delay * attempt, 20)
logger.warning(
"DashscopeVideoClient: %s network error, retrying %s/%s in %.1fs: %s",
action_name,
attempt,
max_attempts,
delay,
last_error,
)
time.sleep(delay)
raise RuntimeError(
f"DashScope {action_name} failed after {max_attempts} attempts due to network error: {last_error}"
) from last_error
def _is_retryable_error(self, exc: Exception) -> bool:
message = str(exc).lower()
retry_markers = (
"ssleoferror",
"unexpected_eof",
"eof occurred in violation of protocol",
"connection reset",
"connection aborted",
"remote disconnected",
"max retries exceeded",
"read timed out",
"connect timed out",
"temporarily unavailable",
)
return any(marker in message for marker in retry_markers)
def generate_video(
self,
prompt: str,
image_path: Optional[str],
save_path: str,
model: str = "wan2.7-i2v",
duration: int = 10,
shot_type: str = "multi",
video_ratio: Optional[str] = None,
last_image_path: Optional[str] = None,
first_clip_path: Optional[str] = None,
reference_image_path: Optional[str] = None,
reference_image_paths: Optional[list[str]] = None,
reference_video_paths: Optional[list[str]] = None,
reference_audio_path: Optional[str] = None,
audio_path: Optional[str] = None,
negative_prompt: Optional[str] = None,
resolution: Optional[str] = None,
prompt_extend: Optional[bool] = None,
watermark: bool = False,
seed: Optional[int] = None,
audio: Optional[bool] = None,
) -> str:
"""
图生视频:提交任务 → 等待完成 → 下载到本地
Args:
prompt: 视频描述提示词
image_path: 输入首帧图片本地路径
save_path: 输出视频保存路径
model: 万象视频模型名
duration: 视频时长(秒)
shot_type: 镜头类型,"single" 或 "multi"
video_ratio: 输出画幅比例,如 9:16 / 16:9
last_image_path: 可选尾帧图片本地路径(wan2.7)
first_clip_path: 可选首段视频本地路径(wan2.7 视频续写)
reference_image_path: 可选参考图片路径(videoedit
reference_image_paths: 可选参考图片列表(r2v
reference_video_paths: 可选参考视频列表(r2v
reference_audio_path: 可选参考音频/音色路径(r2v)
audio_path: 可选驱动音频本地路径(wan2.7)
Returns:
video_url: 远端视频 URL
Raises:
FileNotFoundError: 输入图片不存在
RuntimeError: API 调用或下载失败
"""
if VideoSynthesis is None:
raise RuntimeError("dashscope package not installed. Run: pip install dashscope")
if image_path and not os.path.exists(image_path):
raise FileNotFoundError(f"输入图片不存在: {image_path}")
if last_image_path and not os.path.exists(last_image_path):
raise FileNotFoundError(f"尾帧图片不存在: {last_image_path}")
if first_clip_path and not os.path.exists(first_clip_path):
raise FileNotFoundError(f"输入视频片段不存在: {first_clip_path}")
if reference_image_path and not os.path.exists(reference_image_path):
raise FileNotFoundError(f"参考图片不存在: {reference_image_path}")
for ref_image_path in reference_image_paths or []:
if ref_image_path and not os.path.exists(ref_image_path):
raise FileNotFoundError(f"参考图片不存在: {ref_image_path}")
for ref_video_path in reference_video_paths or []:
if ref_video_path and not os.path.exists(ref_video_path):
raise FileNotFoundError(f"参考视频不存在: {ref_video_path}")
if reference_audio_path and not os.path.exists(reference_audio_path):
raise FileNotFoundError(f"参考音频不存在: {reference_audio_path}")
if audio_path and not os.path.exists(audio_path):
raise FileNotFoundError(f"驱动音频不存在: {audio_path}")
logger.info(f"DashscopeVideoClient: model={model}, prompt={prompt[:60]}...")
if self._is_text_to_video_model(model):
call_kwargs = {
"api_key": self.api_key,
"model": model,
"prompt": prompt,
"duration": duration,
"watermark": watermark,
}
if negative_prompt:
call_kwargs["negative_prompt"] = negative_prompt
if resolution:
call_kwargs["resolution"] = resolution
if video_ratio:
call_kwargs["ratio"] = video_ratio
if prompt_extend is not None:
call_kwargs["prompt_extend"] = prompt_extend
if seed is not None:
call_kwargs["seed"] = seed
if audio is not None:
call_kwargs["audio"] = audio
rsp = self._with_network_retry(
"submit task",
lambda: VideoSynthesis.call(**call_kwargs),
)
elif self._is_reference_to_video_model(model):
media = self._build_reference_to_video_media(
image_path=image_path,
reference_image_path=reference_image_path,
reference_image_paths=reference_image_paths,
reference_video_paths=reference_video_paths,
reference_audio_path=None if "happyhorse" in model.lower() else reference_audio_path,
)
if not media:
raise ValueError("DashScope reference-to-video models require at least one reference_image or reference_video input.")
call_kwargs = {
"api_key": self.api_key,
"model": model,
"prompt": prompt,
"media": media,
"duration": duration,
"watermark": watermark,
}
if audio is not None:
call_kwargs["audio"] = audio
if negative_prompt:
call_kwargs["negative_prompt"] = negative_prompt
if resolution:
call_kwargs["resolution"] = resolution
if video_ratio:
call_kwargs["ratio"] = video_ratio
if prompt_extend is not None:
call_kwargs["prompt_extend"] = prompt_extend
if seed is not None:
call_kwargs["seed"] = seed
rsp = self._with_network_retry(
"submit task",
lambda: VideoSynthesis.call(**call_kwargs),
)
elif self._is_video_edit_model(model):
media = self._build_video_edit_media(
video_path=first_clip_path,
reference_image_path=reference_image_path or last_image_path or image_path,
)
if not media:
raise ValueError("DashScope video edit models require video input and may use reference_image input.")
call_kwargs = {
"api_key": self.api_key,
"model": model,
"prompt": prompt,
"media": media,
"duration": duration,
"watermark": watermark,
}
if negative_prompt:
call_kwargs["negative_prompt"] = negative_prompt
if resolution:
call_kwargs["resolution"] = resolution
if video_ratio:
call_kwargs["ratio"] = video_ratio
if prompt_extend is not None:
call_kwargs["prompt_extend"] = prompt_extend
if seed is not None:
call_kwargs["seed"] = seed
rsp = self._with_network_retry(
"submit task",
lambda: VideoSynthesis.call(**call_kwargs),
)
elif model.startswith("wan2.7") or "happyhorse" in model:
# wan2.7 series use the new API format with 'media'
media = self._build_media(
image_path=image_path,
last_image_path=last_image_path,
first_clip_path=first_clip_path,
audio_path=audio_path,
)
if not media:
raise ValueError("DashScope wan2.7 video generation requires first_frame or first_clip input.")
self._validate_media_combination(media)
call_kwargs = {
"api_key": self.api_key,
"model": model,
"prompt": prompt,
"media": media,
"duration": duration,
"watermark": watermark,
}
if negative_prompt:
call_kwargs["negative_prompt"] = negative_prompt
if resolution:
call_kwargs["resolution"] = resolution
if video_ratio:
call_kwargs["ratio"] = video_ratio
if prompt_extend is not None:
call_kwargs["prompt_extend"] = prompt_extend
if seed is not None:
call_kwargs["seed"] = seed
rsp = self._with_network_retry(
"submit task",
lambda: VideoSynthesis.call(**call_kwargs),
)
else:
# Older models (wan2.1, wan2.6 etc.) use 'img_url' and 'shot_type'
if not image_path:
raise ValueError("DashScope legacy video models require image_path.")
call_kwargs = {
"api_key": self.api_key,
"model": model,
"prompt": prompt,
"img_url": self._to_media_url(image_path),
"duration": duration,
"shot_type": shot_type,
}
if negative_prompt:
call_kwargs["negative_prompt"] = negative_prompt
if resolution:
call_kwargs["resolution"] = resolution
if video_ratio:
call_kwargs["ratio"] = video_ratio
if prompt_extend is not None:
call_kwargs["prompt_extend"] = prompt_extend
if watermark is not None:
call_kwargs["watermark"] = watermark
if seed is not None:
call_kwargs["seed"] = seed
rsp = self._with_network_retry(
"submit task",
lambda: VideoSynthesis.call(**call_kwargs),
)
if rsp.status_code != HTTPStatus.OK:
raise RuntimeError(
f"万象视频 API 错误: status={rsp.status_code}, "
f"code={rsp.code}, message={rsp.message}"
)
video_url = self._extract_video_url(rsp)
if not video_url:
task_id = self._extract_task_id(rsp)
task_status = self._extract_task_status(rsp)
if not task_id:
raise RuntimeError(
"万象视频 API 未返回 video_url 或 task_id,无法查询结果: "
f"status={rsp.status_code}, code={rsp.code}, message={rsp.message}, "
f"task_status={task_status}"
)
logger.info(f"DashscopeVideoClient: 任务已提交 task_id={task_id}, status={task_status}; 等待生成完成...")
rsp = self._with_network_retry(
f"wait task {task_id}",
lambda: VideoSynthesis.wait(task=rsp, api_key=self.api_key),
max_attempts=8,
base_delay=5.0,
)
if rsp.status_code != HTTPStatus.OK:
raise RuntimeError(
f"万象视频任务查询失败: status={rsp.status_code}, "
f"code={rsp.code}, message={rsp.message}, task_id={task_id}"
)
video_url = self._extract_video_url(rsp)
task_status = self._extract_task_status(rsp)
if not video_url:
raise RuntimeError(
"万象视频任务完成后仍未返回 video_url: "
f"code={rsp.code}, message={rsp.message}, task_id={task_id}, task_status={task_status}, "
f"output={self._safe_output_repr(rsp)}"
)
logger.info(f"DashscopeVideoClient: 视频生成成功: {video_url}")
# 确保输出目录存在
os.makedirs(os.path.dirname(save_path), exist_ok=True)
# 下载视频
resp = self._with_network_retry(
"download video",
lambda: requests.get(
video_url,
stream=True,
timeout=120,
proxies={"http": self.local_proxy, "https": self.local_proxy} if self.local_proxy else None,
),
max_attempts=5,
base_delay=3.0,
)
if resp.status_code != 200:
raise RuntimeError(f"视频下载失败: HTTP {resp.status_code}")
with open(save_path, 'wb') as f:
for chunk in resp.iter_content(chunk_size=8192):
if chunk:
f.write(chunk)
logger.info(f"DashscopeVideoClient: 视频已保存: {save_path}")
return video_url
def _is_video_edit_model(self, model: str) -> bool:
"""Return True for DashScope video-edit model IDs."""
model_lower = model.lower()
return "videoedit" in model_lower or "video-edit" in model_lower
def _is_reference_to_video_model(self, model: str) -> bool:
"""Return True for DashScope reference-to-video model IDs."""
return "r2v" in model.lower()
def _is_text_to_video_model(self, model: str) -> bool:
"""Return True for DashScope text-to-video model IDs."""
return "t2v" in model.lower()
def _extract_video_url(self, rsp) -> Optional[str]:
"""Extract video_url from DashScope SDK response variants."""
output = getattr(rsp, "output", None)
if output is None:
return None
if isinstance(output, dict):
return output.get("video_url")
return getattr(output, "video_url", None)
def _extract_task_id(self, rsp) -> Optional[str]:
"""Extract async task_id from DashScope SDK response variants."""
output = getattr(rsp, "output", None)
if output is None:
return None
if isinstance(output, dict):
return output.get("task_id")
return getattr(output, "task_id", None)
def _extract_task_status(self, rsp) -> Optional[str]:
"""Extract async task status from DashScope SDK response variants."""
output = getattr(rsp, "output", None)
if output is None:
return None
if isinstance(output, dict):
return output.get("task_status")
return getattr(output, "task_status", None)
def _safe_output_repr(self, rsp) -> str:
"""Best-effort output representation for provider-side task failures."""
output = getattr(rsp, "output", None)
try:
if isinstance(output, dict):
return str(output)
if hasattr(output, "__dict__"):
return str(output.__dict__)
return str(output)
except Exception:
return "<unprintable output>"
def _build_media(
self,
image_path: Optional[str],
last_image_path: Optional[str],
first_clip_path: Optional[str],
audio_path: Optional[str],
) -> list[dict[str, str]]:
"""Build DashScope wan2.7 media array using official media types."""
media = []
if first_clip_path:
media.append({"type": "first_clip", "url": self._to_media_url(first_clip_path)})
elif image_path:
media.append({"type": "first_frame", "url": self._to_media_url(image_path)})
if last_image_path:
media.append({"type": "last_frame", "url": self._to_media_url(last_image_path)})
if audio_path:
media.append({"type": "driving_audio", "url": self._to_media_url(audio_path)})
return media
def _build_video_edit_media(
self,
video_path: Optional[str],
reference_image_path: Optional[str],
) -> list[dict[str, str]]:
"""Build DashScope video-edit media array using official media types."""
media = []
if video_path:
media.append({"type": "video", "url": self._to_media_url(video_path)})
if reference_image_path:
media.append({"type": "reference_image", "url": self._to_media_url(reference_image_path)})
return media
def _build_reference_to_video_media(
self,
image_path: Optional[str],
reference_image_path: Optional[str],
reference_image_paths: Optional[list[str]],
reference_video_paths: Optional[list[str]],
reference_audio_path: Optional[str],
) -> list[dict[str, str]]:
"""Build DashScope r2v media array using reference_image/reference_video items."""
media = []
image_refs = []
if reference_image_paths:
image_refs.extend(reference_image_paths)
if reference_image_path:
image_refs.append(reference_image_path)
if image_path:
image_refs.append(image_path)
seen = set()
for index, ref_path in enumerate(image_refs):
if not ref_path or ref_path in seen:
continue
seen.add(ref_path)
item = {"type": "reference_image", "url": self._to_media_url(ref_path)}
if index == 0 and reference_audio_path:
item["reference_voice"] = self._to_media_url(reference_audio_path)
media.append(item)
for ref_video_path in reference_video_paths or []:
if ref_video_path:
media.append({"type": "reference_video", "url": self._to_media_url(ref_video_path)})
return media
def _validate_media_combination(self, media: list[dict[str, str]]) -> None:
"""Validate combinations documented by DashScope wan2.7 i2v."""
media_types = {item["type"] for item in media}
allowed = [
{"first_frame"},
{"first_frame", "driving_audio"},
{"first_frame", "last_frame"},
{"first_frame", "last_frame", "driving_audio"},
{"first_clip"},
{"first_clip", "last_frame"},
]
if media_types not in allowed:
raise ValueError(
"Invalid DashScope media combination: "
f"{'+'.join(sorted(media_types))}. "
"Allowed: first_frame, first_frame+driving_audio, first_frame+last_frame, "
"first_frame+last_frame+driving_audio, first_clip, first_clip+last_frame."
)
def _to_media_url(self, path_or_url: str) -> str:
"""Convert a local path to file:// while preserving URL/data/OSS inputs."""
if path_or_url.startswith(("http://", "https://", "file://", "oss://", "data:")):
return path_or_url
return f"file://{os.path.abspath(path_or_url)}"
if __name__ == "__main__":
import sys
import time
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from config import Config
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
# ── 测试参数(按需修改) ──
IMAGE_PATH = "code/result/image/test_avail/test_input_human.jpg"
OUTPUT_DIR = "code/result/video/test_avail"
PROMPT = "女人把报表交给男人,男人看清楚报表上的数据,露出满意的微笑,办公室背景,写实风格,高清细节。背景音乐:轻快的电子乐,节奏感强,适合办公环境。"
# MODELS = ["wan2.7-i2v", "wan2.6-i2v-flash", "happyhorse-1.0-i2v"]
MODELS = ["happyhorse-1.0-i2v"]
DURATION = 5 # 5 / 10
SHOT_TYPE = "multi" # single / multi
print("=== Dashscope 视频客户端可用性测试 ===")
ak = Config.DASHSCOPE_API_KEY
base_url = Config.DASHSCOPE_BASE_URL
if not ak:
print("✗ DASHSCOPE_API_KEY 未设置,请检查 .env 配置")
sys.exit(1)
if not os.path.exists(IMAGE_PATH):
print(f"✗ 输入图片不存在: {IMAGE_PATH}")
sys.exit(1)
for model in MODELS:
output_path = os.path.join(OUTPUT_DIR, f"{model}.mp4")
print(f"\n测试模型: {model}")
print(f" API Key : {ak[:6]}***{ak[-4:]}")
print(f" Base URL : {base_url}")
print(f" 输入图片 : {IMAGE_PATH}")
print(f" 输出路径 : {output_path}")
print(f" 模型 : {model}")
print(f" 时长 : {DURATION}s")
print(f" 镜头类型 : {SHOT_TYPE}")
if PROMPT:
print(f" 提示词 : {PROMPT[:80]}")
print("-" * 40)
try:
client = DashscopeVideoClient(api_key=ak, base_url=base_url)
print("✓ 客户端初始化成功")
start = time.time()
video_url = client.generate_video(
prompt=PROMPT,
image_path=IMAGE_PATH,
save_path=output_path,
model=model,
duration=DURATION,
shot_type=SHOT_TYPE,
)
elapsed = time.time() - start
print(f"✓ 视频生成完成!耗时 {elapsed:.1f}s")
print(f" 远端 URL : {video_url}")
print(f" 本地文件 : {os.path.abspath(output_path)}")
print(f" 文件大小 : {os.path.getsize(output_path) / 1024 / 1024:.2f} MB")
except Exception as e:
print(f"✗ 失败: {e}")
sys.exit(1)