988 lines
32 KiB
Python
988 lines
32 KiB
Python
# -*- coding: utf-8 -*-
|
|
# pylint: disable=too-many-return-statements,too-many-branches
|
|
# pylint: disable=too-many-statements,too-many-locals
|
|
"""Wan 2.7 video generation tools."""
|
|
|
|
import asyncio
|
|
import base64
|
|
import logging
|
|
import mimetypes
|
|
import threading
|
|
import time
|
|
from http import HTTPStatus
|
|
from pathlib import Path
|
|
from typing import List, Optional
|
|
|
|
import httpx
|
|
from agentscope.message import DataBlock, TextBlock, URLSource
|
|
from agentscope.message import ToolResultState
|
|
from agentscope.tool import ToolChunk
|
|
from pineagents.constant import DEFAULT_MEDIA_DIR
|
|
from pineagents.plugins import get_tool_config
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Thread lock to protect dashscope global base_http_api_url setting
|
|
_DASHSCOPE_LOCK = threading.Lock()
|
|
|
|
_DEFAULT_ENDPOINT = "https://dashscope.aliyuncs.com/api/v1"
|
|
_DEFAULT_TIMEOUT = 600.0
|
|
|
|
_VALID_RESOLUTIONS = {"720P", "1080P"}
|
|
_VALID_RATIOS = {"16:9", "9:16", "1:1", "4:3", "3:4"}
|
|
|
|
_IMAGE_MIME_TYPES = {
|
|
".png": "image/png",
|
|
".jpg": "image/jpeg",
|
|
".jpeg": "image/jpeg",
|
|
".webp": "image/webp",
|
|
}
|
|
|
|
|
|
def _resolve_image_url(path_or_url: str) -> str:
|
|
"""Resolve an image path or URL to a usable URL.
|
|
|
|
If the input is an HTTP/HTTPS URL, return it as-is.
|
|
If the input is a local file path, read the file and return
|
|
a base64 data URL.
|
|
|
|
Args:
|
|
path_or_url: HTTP/HTTPS URL or local file path.
|
|
|
|
Returns:
|
|
str: A URL (original URL or base64 data URL).
|
|
|
|
Raises:
|
|
FileNotFoundError: If the local file does not exist.
|
|
ValueError: If the file format is not supported.
|
|
"""
|
|
if path_or_url.startswith(("http://", "https://")):
|
|
return path_or_url
|
|
|
|
path_obj = Path(path_or_url)
|
|
if not path_obj.exists():
|
|
raise FileNotFoundError(
|
|
f"Image file not found: {path_or_url}",
|
|
)
|
|
if not path_obj.is_file():
|
|
raise ValueError(f"Not a file: {path_or_url}")
|
|
|
|
ext = path_obj.suffix.lower()
|
|
if ext not in _IMAGE_MIME_TYPES:
|
|
raise ValueError(
|
|
f"Unsupported image format: {ext}. "
|
|
f"Supported: {', '.join(_IMAGE_MIME_TYPES.keys())}",
|
|
)
|
|
|
|
mime_type = _IMAGE_MIME_TYPES[ext]
|
|
with open(path_obj, "rb") as f:
|
|
image_data = base64.b64encode(f.read()).decode("utf-8")
|
|
|
|
return f"data:{mime_type};base64,{image_data}"
|
|
|
|
|
|
def _extract_config(
|
|
tool_config: dict,
|
|
) -> tuple[str, str, float]:
|
|
"""Extract api_key, endpoint and timeout from tool config.
|
|
|
|
Args:
|
|
tool_config: Tool configuration dict.
|
|
|
|
Returns:
|
|
Tuple of (api_key, endpoint, timeout).
|
|
"""
|
|
api_key = tool_config.get("api_key", "")
|
|
endpoint = tool_config.get("endpoint", "")
|
|
if not endpoint or not endpoint.strip():
|
|
endpoint = _DEFAULT_ENDPOINT
|
|
|
|
timeout_raw = tool_config.get("timeout")
|
|
if timeout_raw is None or float(timeout_raw) <= 0:
|
|
timeout = _DEFAULT_TIMEOUT
|
|
else:
|
|
timeout = float(timeout_raw)
|
|
|
|
return api_key, endpoint, timeout
|
|
|
|
|
|
async def _download_video(
|
|
video_url: str,
|
|
save_dir: Path,
|
|
prefix: str,
|
|
timeout: float,
|
|
) -> Path:
|
|
"""Download video from URL and save to local directory.
|
|
|
|
Args:
|
|
video_url: Public URL of the video.
|
|
save_dir: Directory to save the video.
|
|
prefix: Filename prefix.
|
|
timeout: HTTP timeout in seconds.
|
|
|
|
Returns:
|
|
Path: Local path of the saved video.
|
|
|
|
Raises:
|
|
Exception: If download fails.
|
|
"""
|
|
save_dir.mkdir(parents=True, exist_ok=True)
|
|
timestamp = int(time.time() * 1000)
|
|
filename = f"{prefix}_{timestamp}.mp4"
|
|
video_path = save_dir / filename
|
|
|
|
async with httpx.AsyncClient(timeout=timeout) as client:
|
|
async with client.stream("GET", video_url) as response:
|
|
response.raise_for_status()
|
|
chunks = []
|
|
async for chunk in response.aiter_bytes(chunk_size=1024 * 1024):
|
|
chunks.append(chunk)
|
|
await asyncio.to_thread(video_path.write_bytes, b"".join(chunks))
|
|
|
|
logger.info(f"Video saved to {video_path}")
|
|
return video_path
|
|
|
|
|
|
def _call_video_synthesis(
|
|
api_key: str,
|
|
endpoint: str,
|
|
model: str,
|
|
prompt: str,
|
|
**kwargs,
|
|
):
|
|
"""Call DashScope VideoSynthesis with thread-safe endpoint setup.
|
|
|
|
Args:
|
|
api_key: DashScope API key.
|
|
endpoint: Base HTTP API URL.
|
|
model: Model name.
|
|
prompt: Text prompt.
|
|
**kwargs: Additional parameters passed to VideoSynthesis.call().
|
|
|
|
Returns:
|
|
SDK response object.
|
|
"""
|
|
import dashscope
|
|
from dashscope import VideoSynthesis
|
|
|
|
with _DASHSCOPE_LOCK:
|
|
dashscope.base_http_api_url = endpoint
|
|
rsp = VideoSynthesis.call(
|
|
api_key=api_key,
|
|
model=model,
|
|
prompt=prompt,
|
|
**kwargs,
|
|
)
|
|
|
|
return rsp
|
|
|
|
|
|
async def text_to_video_wan(
|
|
prompt: str,
|
|
resolution: str = "720P",
|
|
ratio: str = "16:9",
|
|
duration: int = 5,
|
|
negative_prompt: str = "",
|
|
audio_url: str = "",
|
|
prompt_extend: bool = True,
|
|
) -> ToolChunk:
|
|
"""Generate a video from a text prompt using Wan 2.7.
|
|
|
|
Uses Alibaba Cloud's wan2.7-t2v model to create videos from
|
|
natural language descriptions. Supports multi-shot narratives,
|
|
custom audio, and negative prompts.
|
|
|
|
Args:
|
|
prompt (str):
|
|
Text description of the video to generate.
|
|
Supports Chinese and English, up to 5000 characters.
|
|
Use timestamps for multi-shot control, e.g.:
|
|
"第1个镜头[0-3秒] 全景:..."
|
|
resolution (str, optional):
|
|
Video resolution. "720P" or "1080P". Default: "720P".
|
|
ratio (str, optional):
|
|
Aspect ratio. "16:9", "9:16", "1:1", "4:3", "3:4".
|
|
Default: "16:9".
|
|
duration (int, optional):
|
|
Video duration in seconds, range [2, 15]. Default: 5.
|
|
negative_prompt (str, optional):
|
|
Describe what to exclude from the video.
|
|
audio_url (str, optional):
|
|
Public HTTP/HTTPS URL of background audio (wav/mp3).
|
|
If not provided, model auto-generates audio.
|
|
prompt_extend (bool, optional):
|
|
Enable prompt auto-optimization. Default: True.
|
|
|
|
Returns:
|
|
ToolChunk: Contains local video path and metadata.
|
|
"""
|
|
try:
|
|
tool_config = get_tool_config("text_to_video_wan")
|
|
if not tool_config:
|
|
return ToolChunk(
|
|
state=ToolResultState.ERROR,
|
|
content=[
|
|
TextBlock(
|
|
type="text",
|
|
text=(
|
|
"Error: Tool not configured. "
|
|
"Please set your API key in the tool settings."
|
|
),
|
|
),
|
|
],
|
|
)
|
|
|
|
api_key, endpoint, timeout = _extract_config(tool_config)
|
|
if not api_key:
|
|
return ToolChunk(
|
|
state=ToolResultState.ERROR,
|
|
content=[
|
|
TextBlock(
|
|
type="text",
|
|
text=(
|
|
"Error: DashScope API key not configured. "
|
|
"Please set your API key in the tool settings."
|
|
),
|
|
),
|
|
],
|
|
)
|
|
|
|
if resolution not in _VALID_RESOLUTIONS:
|
|
return ToolChunk(
|
|
state=ToolResultState.ERROR,
|
|
content=[
|
|
TextBlock(
|
|
type="text",
|
|
text=(
|
|
f"Error: Invalid resolution '{resolution}'. "
|
|
f"Must be one of: "
|
|
f"{', '.join(sorted(_VALID_RESOLUTIONS))}"
|
|
),
|
|
),
|
|
],
|
|
)
|
|
|
|
if ratio not in _VALID_RATIOS:
|
|
return ToolChunk(
|
|
state=ToolResultState.ERROR,
|
|
content=[
|
|
TextBlock(
|
|
type="text",
|
|
text=(
|
|
f"Error: Invalid ratio '{ratio}'. "
|
|
f"Must be one of: "
|
|
f"{', '.join(sorted(_VALID_RATIOS))}"
|
|
),
|
|
),
|
|
],
|
|
)
|
|
|
|
if not 2 <= duration <= 15:
|
|
return ToolChunk(
|
|
state=ToolResultState.ERROR,
|
|
content=[
|
|
TextBlock(
|
|
type="text",
|
|
text=(
|
|
f"Error: Invalid duration '{duration}'. "
|
|
f"Must be between 2 and 15 seconds."
|
|
),
|
|
),
|
|
],
|
|
)
|
|
|
|
logger.info(
|
|
f"Generating text-to-video: model=wan2.7-t2v-2026-04-25, "
|
|
f"resolution={resolution}, ratio={ratio}, "
|
|
f"duration={duration}s",
|
|
)
|
|
|
|
kwargs = {
|
|
"resolution": resolution,
|
|
"ratio": ratio,
|
|
"duration": duration,
|
|
"prompt_extend": prompt_extend,
|
|
}
|
|
if negative_prompt:
|
|
kwargs["negative_prompt"] = negative_prompt
|
|
if audio_url:
|
|
kwargs["audio_url"] = audio_url
|
|
|
|
rsp = await asyncio.to_thread(
|
|
_call_video_synthesis,
|
|
api_key=api_key,
|
|
endpoint=endpoint,
|
|
model="wan2.7-t2v-2026-04-25",
|
|
prompt=prompt,
|
|
**kwargs,
|
|
)
|
|
|
|
if rsp.status_code != HTTPStatus.OK:
|
|
error_msg = (
|
|
f"DashScope API error: {rsp.status_code} - "
|
|
f"{rsp.code}: {rsp.message}"
|
|
)
|
|
logger.error(error_msg)
|
|
return ToolChunk(
|
|
state=ToolResultState.ERROR,
|
|
content=[
|
|
TextBlock(
|
|
type="text",
|
|
text=f"Error: {error_msg}",
|
|
),
|
|
],
|
|
)
|
|
|
|
video_url = rsp.output.video_url
|
|
logger.info(
|
|
f"Text-to-video generated, downloading: {video_url}",
|
|
)
|
|
|
|
save_dir = DEFAULT_MEDIA_DIR / "wan27"
|
|
video_path = await _download_video(
|
|
video_url,
|
|
save_dir,
|
|
"t2v",
|
|
timeout,
|
|
)
|
|
|
|
return ToolChunk(
|
|
state=ToolResultState.SUCCESS,
|
|
content=[
|
|
DataBlock(
|
|
source=URLSource(
|
|
url="file://" + str(video_path),
|
|
media_type=mimetypes.guess_type(str(video_path))[0]
|
|
or "video/*",
|
|
),
|
|
),
|
|
TextBlock(
|
|
type="text",
|
|
text=(
|
|
f"Video generated successfully using Wan 2.7\n"
|
|
f"Model: wan2.7-t2v-2026-04-25\n"
|
|
f"Prompt: {prompt}\n"
|
|
f"Resolution: {resolution}, Ratio: {ratio}, "
|
|
f"Duration: {duration}s\n"
|
|
f"Saved to: {video_path}\n"
|
|
f"Original URL (valid 24h): {video_url}"
|
|
),
|
|
),
|
|
],
|
|
)
|
|
|
|
except Exception as e:
|
|
logger.error(
|
|
f"Text-to-video generation failed: {e}",
|
|
exc_info=True,
|
|
)
|
|
return ToolChunk(
|
|
state=ToolResultState.ERROR,
|
|
content=[
|
|
TextBlock(
|
|
type="text",
|
|
text=f"Error: Text-to-video generation failed - {str(e)}",
|
|
),
|
|
],
|
|
)
|
|
|
|
|
|
async def image_to_video_wan(
|
|
prompt: str,
|
|
first_frame_url: str,
|
|
last_frame_url: str = "",
|
|
driving_audio_url: str = "",
|
|
first_clip_url: str = "",
|
|
resolution: str = "720P",
|
|
duration: int = 5,
|
|
prompt_extend: bool = True,
|
|
) -> ToolChunk:
|
|
"""Generate a video from images using Wan 2.7.
|
|
|
|
Supports four modes based on the combination of optional inputs:
|
|
- First-frame: provide only first_frame_url (+ optional audio)
|
|
- First-last-frame: provide first_frame_url + last_frame_url
|
|
- Audio-driven: provide first_frame_url + driving_audio_url
|
|
- Video-continuation: provide first_clip_url (ignores first_frame_url)
|
|
|
|
Args:
|
|
prompt (str):
|
|
Text description of the video content.
|
|
first_frame_url (str):
|
|
URL or local file path of the first frame image.
|
|
Supports HTTP/HTTPS URLs and local image files
|
|
(.png/.jpg/.jpeg/.webp).
|
|
last_frame_url (str, optional):
|
|
URL or local file path of the last frame image.
|
|
When provided, creates a first-to-last-frame video.
|
|
driving_audio_url (str, optional):
|
|
Public HTTP/HTTPS URL of a driving audio file (wav/mp3,
|
|
2-30 seconds). Local audio files are not supported.
|
|
first_clip_url (str, optional):
|
|
Public HTTP/HTTPS URL of a video clip to continue.
|
|
When provided, generates a continuation of the clip.
|
|
Local video files are not supported.
|
|
resolution (str, optional):
|
|
Video resolution. "720P" or "1080P". Default: "720P".
|
|
duration (int, optional):
|
|
Video duration in seconds, range [2, 15]. Default: 5.
|
|
prompt_extend (bool, optional):
|
|
Enable prompt auto-optimization. Default: True.
|
|
|
|
Returns:
|
|
ToolChunk: Contains local video path and metadata.
|
|
"""
|
|
try:
|
|
tool_config = get_tool_config("image_to_video_wan")
|
|
if not tool_config:
|
|
return ToolChunk(
|
|
state=ToolResultState.ERROR,
|
|
content=[
|
|
TextBlock(
|
|
type="text",
|
|
text=(
|
|
"Error: Tool not configured. "
|
|
"Please set your API key in the tool settings."
|
|
),
|
|
),
|
|
],
|
|
)
|
|
|
|
api_key, endpoint, timeout = _extract_config(tool_config)
|
|
if not api_key:
|
|
return ToolChunk(
|
|
state=ToolResultState.ERROR,
|
|
content=[
|
|
TextBlock(
|
|
type="text",
|
|
text=(
|
|
"Error: DashScope API key not configured. "
|
|
"Please set your API key in the tool settings."
|
|
),
|
|
),
|
|
],
|
|
)
|
|
|
|
if resolution not in _VALID_RESOLUTIONS:
|
|
return ToolChunk(
|
|
state=ToolResultState.ERROR,
|
|
content=[
|
|
TextBlock(
|
|
type="text",
|
|
text=(
|
|
f"Error: Invalid resolution '{resolution}'. "
|
|
f"Must be one of: "
|
|
f"{', '.join(sorted(_VALID_RESOLUTIONS))}"
|
|
),
|
|
),
|
|
],
|
|
)
|
|
|
|
if not 2 <= duration <= 15:
|
|
return ToolChunk(
|
|
state=ToolResultState.ERROR,
|
|
content=[
|
|
TextBlock(
|
|
type="text",
|
|
text=(
|
|
f"Error: Invalid duration '{duration}'. "
|
|
f"Must be between 2 and 15 seconds."
|
|
),
|
|
),
|
|
],
|
|
)
|
|
|
|
# Build media array based on input combination
|
|
media: List[dict] = []
|
|
|
|
if first_clip_url:
|
|
# Video continuation mode
|
|
if not first_clip_url.startswith(
|
|
("http://", "https://"),
|
|
):
|
|
return ToolChunk(
|
|
state=ToolResultState.ERROR,
|
|
content=[
|
|
TextBlock(
|
|
type="text",
|
|
text=(
|
|
"Error: first_clip_url must be a public "
|
|
"HTTP/HTTPS URL. Local video files are "
|
|
"not supported."
|
|
),
|
|
),
|
|
],
|
|
)
|
|
media.append(
|
|
{"type": "first_clip", "url": first_clip_url},
|
|
)
|
|
else:
|
|
if not first_frame_url:
|
|
return ToolChunk(
|
|
state=ToolResultState.ERROR,
|
|
content=[
|
|
TextBlock(
|
|
type="text",
|
|
text=(
|
|
"Error: first_frame_url is required "
|
|
"(unless using video-continuation mode "
|
|
"with first_clip_url)."
|
|
),
|
|
),
|
|
],
|
|
)
|
|
|
|
try:
|
|
resolved_first = _resolve_image_url(first_frame_url)
|
|
except (FileNotFoundError, ValueError) as e:
|
|
return ToolChunk(
|
|
state=ToolResultState.ERROR,
|
|
content=[
|
|
TextBlock(
|
|
type="text",
|
|
text=f"Error: first_frame_url - {str(e)}",
|
|
),
|
|
],
|
|
)
|
|
|
|
media.append(
|
|
{"type": "first_frame", "url": resolved_first},
|
|
)
|
|
|
|
if last_frame_url:
|
|
try:
|
|
resolved_last = _resolve_image_url(
|
|
last_frame_url,
|
|
)
|
|
except (FileNotFoundError, ValueError) as e:
|
|
return ToolChunk(
|
|
state=ToolResultState.ERROR,
|
|
content=[
|
|
TextBlock(
|
|
type="text",
|
|
text=f"Error: last_frame_url - {str(e)}",
|
|
),
|
|
],
|
|
)
|
|
media.append(
|
|
{"type": "last_frame", "url": resolved_last},
|
|
)
|
|
elif driving_audio_url:
|
|
if not driving_audio_url.startswith(
|
|
("http://", "https://"),
|
|
):
|
|
return ToolChunk(
|
|
state=ToolResultState.ERROR,
|
|
content=[
|
|
TextBlock(
|
|
type="text",
|
|
text=(
|
|
"Error: driving_audio_url must be a "
|
|
"public HTTP/HTTPS URL. Local audio "
|
|
"files are not supported."
|
|
),
|
|
),
|
|
],
|
|
)
|
|
media.append(
|
|
{
|
|
"type": "driving_audio",
|
|
"url": driving_audio_url,
|
|
},
|
|
)
|
|
|
|
mode_desc = (
|
|
"video-continuation"
|
|
if first_clip_url
|
|
else "first-last-frame"
|
|
if last_frame_url
|
|
else "audio-driven"
|
|
if driving_audio_url
|
|
else "first-frame"
|
|
)
|
|
|
|
logger.info(
|
|
f"Generating image-to-video: mode={mode_desc}, "
|
|
f"model=wan2.7-i2v-2026-04-25, "
|
|
f"resolution={resolution}, duration={duration}s",
|
|
)
|
|
|
|
rsp = await asyncio.to_thread(
|
|
_call_video_synthesis,
|
|
api_key=api_key,
|
|
endpoint=endpoint,
|
|
model="wan2.7-i2v-2026-04-25",
|
|
prompt=prompt,
|
|
media=media,
|
|
resolution=resolution,
|
|
duration=duration,
|
|
prompt_extend=prompt_extend,
|
|
)
|
|
|
|
if rsp.status_code != HTTPStatus.OK:
|
|
error_msg = (
|
|
f"DashScope API error: {rsp.status_code} - "
|
|
f"{rsp.code}: {rsp.message}"
|
|
)
|
|
logger.error(error_msg)
|
|
return ToolChunk(
|
|
state=ToolResultState.ERROR,
|
|
content=[
|
|
TextBlock(
|
|
type="text",
|
|
text=f"Error: {error_msg}",
|
|
),
|
|
],
|
|
)
|
|
|
|
video_url = rsp.output.video_url
|
|
logger.info(
|
|
f"Image-to-video generated, downloading: {video_url}",
|
|
)
|
|
|
|
save_dir = DEFAULT_MEDIA_DIR / "wan27"
|
|
video_path = await _download_video(
|
|
video_url,
|
|
save_dir,
|
|
"i2v",
|
|
timeout,
|
|
)
|
|
|
|
return ToolChunk(
|
|
state=ToolResultState.SUCCESS,
|
|
content=[
|
|
DataBlock(
|
|
source=URLSource(
|
|
url="file://" + str(video_path),
|
|
media_type=mimetypes.guess_type(str(video_path))[0]
|
|
or "video/*",
|
|
),
|
|
),
|
|
TextBlock(
|
|
type="text",
|
|
text=(
|
|
f"Video generated successfully using Wan 2.7\n"
|
|
f"Model: wan2.7-i2v-2026-04-25\n"
|
|
f"Mode: {mode_desc}\n"
|
|
f"Prompt: {prompt}\n"
|
|
f"Resolution: {resolution}, Duration: {duration}s\n"
|
|
f"Saved to: {video_path}\n"
|
|
f"Original URL (valid 24h): {video_url}"
|
|
),
|
|
),
|
|
],
|
|
)
|
|
|
|
except Exception as e:
|
|
logger.error(
|
|
f"Image-to-video generation failed: {e}",
|
|
exc_info=True,
|
|
)
|
|
return ToolChunk(
|
|
state=ToolResultState.ERROR,
|
|
content=[
|
|
TextBlock(
|
|
type="text",
|
|
text=(
|
|
f"Error: Image-to-video generation failed - {str(e)}"
|
|
),
|
|
),
|
|
],
|
|
)
|
|
|
|
|
|
async def reference_to_video_wan(
|
|
prompt: str,
|
|
reference_images: List[str],
|
|
reference_videos: Optional[List[str]] = None,
|
|
first_frame_url: str = "",
|
|
resolution: str = "720P",
|
|
ratio: str = "16:9",
|
|
duration: int = 5,
|
|
prompt_extend: bool = True,
|
|
) -> ToolChunk:
|
|
"""Generate a video with character/object references using Wan 2.7.
|
|
|
|
Uses reference images and/or videos to maintain character
|
|
consistency in the generated video. In the prompt, reference
|
|
images as "图1", "图2", etc. and reference videos as "视频1",
|
|
"视频2", etc. (or "Image 1", "Video 1" in English prompts).
|
|
|
|
Args:
|
|
prompt (str):
|
|
Text description of the video. Use "图1", "图2" to
|
|
refer to reference images, "视频1", "视频2" to refer
|
|
to reference videos (in order of the lists provided).
|
|
Example: "图1在图2的房间里玩耍,视频1走进来"
|
|
reference_images (List[str]):
|
|
List of reference image URLs or local file paths
|
|
(.png/.jpg/.jpeg/.webp). Minimum 1 image.
|
|
These become 图1, 图2, ... in the prompt.
|
|
reference_videos (List[str], optional):
|
|
List of reference video public HTTP/HTTPS URLs.
|
|
Local video files are not supported.
|
|
These become 视频1, 视频2, ... in the prompt.
|
|
first_frame_url (str, optional):
|
|
URL or local file path of an additional first-frame
|
|
image to control the opening scene.
|
|
resolution (str, optional):
|
|
Video resolution. "720P" or "1080P". Default: "720P".
|
|
ratio (str, optional):
|
|
Aspect ratio. "16:9", "9:16", "1:1", "4:3", "3:4".
|
|
Default: "16:9".
|
|
duration (int, optional):
|
|
Video duration in seconds, range [2, 15]. Default: 5.
|
|
prompt_extend (bool, optional):
|
|
Enable prompt auto-optimization. Default: True.
|
|
|
|
Returns:
|
|
ToolChunk: Contains local video path and metadata.
|
|
"""
|
|
if reference_videos is None:
|
|
reference_videos = []
|
|
|
|
try:
|
|
tool_config = get_tool_config("reference_to_video_wan")
|
|
if not tool_config:
|
|
return ToolChunk(
|
|
state=ToolResultState.ERROR,
|
|
content=[
|
|
TextBlock(
|
|
type="text",
|
|
text=(
|
|
"Error: Tool not configured. "
|
|
"Please set your API key in the tool settings."
|
|
),
|
|
),
|
|
],
|
|
)
|
|
|
|
api_key, endpoint, timeout = _extract_config(tool_config)
|
|
if not api_key:
|
|
return ToolChunk(
|
|
state=ToolResultState.ERROR,
|
|
content=[
|
|
TextBlock(
|
|
type="text",
|
|
text=(
|
|
"Error: DashScope API key not configured. "
|
|
"Please set your API key in the tool settings."
|
|
),
|
|
),
|
|
],
|
|
)
|
|
|
|
if not reference_images:
|
|
return ToolChunk(
|
|
state=ToolResultState.ERROR,
|
|
content=[
|
|
TextBlock(
|
|
type="text",
|
|
text=(
|
|
"Error: reference_images is required. "
|
|
"Please provide at least one reference image."
|
|
),
|
|
),
|
|
],
|
|
)
|
|
|
|
if resolution not in _VALID_RESOLUTIONS:
|
|
return ToolChunk(
|
|
state=ToolResultState.ERROR,
|
|
content=[
|
|
TextBlock(
|
|
type="text",
|
|
text=(
|
|
f"Error: Invalid resolution '{resolution}'. "
|
|
f"Must be one of: "
|
|
f"{', '.join(sorted(_VALID_RESOLUTIONS))}"
|
|
),
|
|
),
|
|
],
|
|
)
|
|
|
|
if ratio not in _VALID_RATIOS:
|
|
return ToolChunk(
|
|
state=ToolResultState.ERROR,
|
|
content=[
|
|
TextBlock(
|
|
type="text",
|
|
text=(
|
|
f"Error: Invalid ratio '{ratio}'. "
|
|
f"Must be one of: "
|
|
f"{', '.join(sorted(_VALID_RATIOS))}"
|
|
),
|
|
),
|
|
],
|
|
)
|
|
|
|
if not 2 <= duration <= 15:
|
|
return ToolChunk(
|
|
state=ToolResultState.ERROR,
|
|
content=[
|
|
TextBlock(
|
|
type="text",
|
|
text=(
|
|
f"Error: Invalid duration '{duration}'. "
|
|
f"Must be between 2 and 15 seconds."
|
|
),
|
|
),
|
|
],
|
|
)
|
|
|
|
# Build media array: reference_images first, then videos,
|
|
# then optional first_frame
|
|
media: List[dict] = []
|
|
|
|
for img_path in reference_images:
|
|
try:
|
|
resolved = _resolve_image_url(img_path)
|
|
except (FileNotFoundError, ValueError) as e:
|
|
return ToolChunk(
|
|
state=ToolResultState.ERROR,
|
|
content=[
|
|
TextBlock(
|
|
type="text",
|
|
text=(
|
|
f"Error: reference_images contains invalid "
|
|
f"entry '{img_path}' - {str(e)}"
|
|
),
|
|
),
|
|
],
|
|
)
|
|
media.append(
|
|
{"type": "reference_image", "url": resolved},
|
|
)
|
|
|
|
for vid_url in reference_videos:
|
|
if not vid_url.startswith(("http://", "https://")):
|
|
return ToolChunk(
|
|
state=ToolResultState.ERROR,
|
|
content=[
|
|
TextBlock(
|
|
type="text",
|
|
text=(
|
|
f"Error: reference_videos entry '{vid_url}' "
|
|
f"must be a public HTTP/HTTPS URL. Local "
|
|
f"video files are not supported."
|
|
),
|
|
),
|
|
],
|
|
)
|
|
media.append(
|
|
{"type": "reference_video", "url": vid_url},
|
|
)
|
|
|
|
if first_frame_url:
|
|
try:
|
|
resolved_ff = _resolve_image_url(first_frame_url)
|
|
except (FileNotFoundError, ValueError) as e:
|
|
return ToolChunk(
|
|
state=ToolResultState.ERROR,
|
|
content=[
|
|
TextBlock(
|
|
type="text",
|
|
text=f"Error: first_frame_url - {str(e)}",
|
|
),
|
|
],
|
|
)
|
|
media.append(
|
|
{"type": "first_frame", "url": resolved_ff},
|
|
)
|
|
|
|
logger.info(
|
|
f"Generating reference-to-video: "
|
|
f"model=wan2.7-r2v, "
|
|
f"reference_images={len(reference_images)}, "
|
|
f"reference_videos={len(reference_videos)}, "
|
|
f"resolution={resolution}, ratio={ratio}, "
|
|
f"duration={duration}s",
|
|
)
|
|
|
|
rsp = await asyncio.to_thread(
|
|
_call_video_synthesis,
|
|
api_key=api_key,
|
|
endpoint=endpoint,
|
|
model="wan2.7-r2v",
|
|
prompt=prompt,
|
|
media=media,
|
|
resolution=resolution,
|
|
ratio=ratio,
|
|
duration=duration,
|
|
prompt_extend=prompt_extend,
|
|
)
|
|
|
|
if rsp.status_code != HTTPStatus.OK:
|
|
error_msg = (
|
|
f"DashScope API error: {rsp.status_code} - "
|
|
f"{rsp.code}: {rsp.message}"
|
|
)
|
|
logger.error(error_msg)
|
|
return ToolChunk(
|
|
state=ToolResultState.ERROR,
|
|
content=[
|
|
TextBlock(
|
|
type="text",
|
|
text=f"Error: {error_msg}",
|
|
),
|
|
],
|
|
)
|
|
|
|
video_url = rsp.output.video_url
|
|
logger.info(
|
|
f"Reference-to-video generated, downloading: " f"{video_url}",
|
|
)
|
|
|
|
save_dir = DEFAULT_MEDIA_DIR / "wan27"
|
|
video_path = await _download_video(
|
|
video_url,
|
|
save_dir,
|
|
"r2v",
|
|
timeout,
|
|
)
|
|
|
|
return ToolChunk(
|
|
state=ToolResultState.SUCCESS,
|
|
content=[
|
|
DataBlock(
|
|
source=URLSource(
|
|
url="file://" + str(video_path),
|
|
media_type=mimetypes.guess_type(str(video_path))[0]
|
|
or "video/*",
|
|
),
|
|
),
|
|
TextBlock(
|
|
type="text",
|
|
text=(
|
|
f"Video generated successfully using Wan 2.7\n"
|
|
f"Model: wan2.7-r2v\n"
|
|
f"Reference images: {len(reference_images)}, "
|
|
f"Reference videos: {len(reference_videos)}\n"
|
|
f"Prompt: {prompt}\n"
|
|
f"Resolution: {resolution}, Ratio: {ratio}, "
|
|
f"Duration: {duration}s\n"
|
|
f"Saved to: {video_path}\n"
|
|
f"Original URL (valid 24h): {video_url}"
|
|
),
|
|
),
|
|
],
|
|
)
|
|
|
|
except Exception as e:
|
|
logger.error(
|
|
f"Reference-to-video generation failed: {e}",
|
|
exc_info=True,
|
|
)
|
|
return ToolChunk(
|
|
state=ToolResultState.ERROR,
|
|
content=[
|
|
TextBlock(
|
|
type="text",
|
|
text=(
|
|
f"Error: Reference-to-video generation failed "
|
|
f"- {str(e)}"
|
|
),
|
|
),
|
|
],
|
|
)
|