Files
MeteorAndyandClaude 6f2aa13575 Align with official API nodes: VIDEO/AUDIO output + real vendor formats
Rewrite each node to faithfully mirror the official comfy_api_nodes/
contract (apis/*.py + Comfy-Org/embedded-docs). The ComfyOrg proxy
forwards the body verbatim, so the official request/response models
ARE the real vendor API.

- Video nodes now return VIDEO (InputImpl.VideoFromFile), not file paths
- Audio nodes now return AUDIO ({waveform, sample_rate}), not file paths
- Request/response bodies match official pydantic models field-for-field
- Model ids, options, defaults, ranges match official + embedded docs
- Every node exposes api_key + base_url (overridable for relays/proxies)
- utils.py: add ComfyUI-native helpers — video_from_bytes,
  download_video_output, audio_from_bytes, download_audio_output,
  poll_until, validate_prompt, tensor_to_data_uri
- Rewrite nodes_minimax.py (submit -> poll -> retrieve -> download VIDEO)
- nodes_extra.py: base_url on all three, poll_until, validate_prompt
- Clean __init__.py (drop duplicate grok import/keys); 37 nodes total
- README: output-type alignment, node list, relay examples

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-15 20:07:04 +08:00

403 lines
15 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Wan (Alibaba DashScope / Tongyi Wanxiang) video generation nodes (bring-your-own-key).
These nodes mirror the official ComfyUI Wan API nodes (``WanTextToVideoApi`` /
``WanImageToVideoApi`` in ``comfy_api_nodes/nodes_wan.py``) but call the Alibaba
DashScope API DIRECTLY instead of going through the ComfyOrg proxy. The official
nodes hit ``/proxy/wan/api/v1/...`` which the proxy forwards verbatim to DashScope,
so the request/response models and endpoint paths used here ARE the real vendor API.
DashScope video synthesis is an asynchronous flow:
1. POST ``/api/v1/services/aigc/video-generation/video-synthesis`` with the
``X-DashScope-Async: enable`` header -> returns ``output.task_id``.
2. GET ``/api/v1/tasks/{task_id}`` (Authorization only) -> poll until
``output.task_status`` is ``SUCCEEDED`` (or ``FAILED``/``UNKNOWN``).
3. On success, download ``output.video_url`` and wrap it as a ComfyUI VIDEO.
Output type is VIDEO (never a string path), matching the official nodes.
"""
import logging
from .utils import (
download_video_output,
http_request,
poll_until,
run_async,
tensor_to_data_uri,
validate_prompt,
)
logger = logging.getLogger("comfyui_byokey")
# Real DashScope host. Path segments mirror the official proxy path
# (``/proxy/wan`` -> ``https://dashscope.aliyuncs.com``). Overridable for relays
# (e.g. the international endpoint ``https://dashscope-intl.aliyuncs.com``).
DEFAULT_BASE_URL = "https://dashscope.aliyuncs.com"
SUBMIT_PATH = "/api/v1/services/aigc/video-generation/video-synthesis"
TASK_PATH = "/api/v1/tasks/{task_id}"
# Resolution / aspect-ratio combos for the size-driven text-to-video node, copied
# verbatim from the official WanTextToVideoApi schema. The "(WIDTHxHEIGHT)" tail is
# parsed out and sent to DashScope as the "WIDTH*HEIGHT" size string.
T2V_SIZE_OPTIONS = [
"480p: 1:1 (624x624)",
"480p: 16:9 (832x480)",
"480p: 9:16 (480x832)",
"720p: 1:1 (960x960)",
"720p: 16:9 (1280x720)",
"720p: 9:16 (720x1280)",
"720p: 4:3 (1088x832)",
"720p: 3:4 (832x1088)",
"1080p: 1:1 (1440x1440)",
"1080p: 16:9 (1920x1080)",
"1080p: 9:16 (1080x1920)",
"1080p: 4:3 (1632x1248)",
"1080p: 3:4 (1248x1632)",
]
# Resolution labels for the image-to-video node (official WanImageToVideoApi).
I2V_RESOLUTION_OPTIONS = ["480P", "720P", "1080P"]
SHOT_TYPE_OPTIONS = ["single", "multi"]
# Task status values returned by DashScope under output.task_status.
DONE_STATUSES = ["SUCCEEDED"]
FAILED_STATUSES = ["FAILED", "UNKNOWN", "CANCELED"]
def _parse_size_from_label(size_label: str) -> str:
"""Extract ``WIDTH*HEIGHT`` from a label like ``"720p: 16:9 (1280x720)"``.
DashScope expects the ``size`` parameter as ``"<width>*<height>"``.
"""
start = size_label.rfind("(")
end = size_label.rfind(")")
if start == -1 or end == -1 or end <= start:
raise ValueError(f"Could not parse resolution from size label: {size_label!r}")
inner = size_label[start + 1:end]
for sep in ("x", "X", "×"): # x, X, ×
if sep in inner:
w, h = inner.split(sep, 1)
return f"{w.strip()}*{h.strip()}"
raise ValueError(f"Could not parse resolution from size label: {size_label!r}")
def _auth_headers(api_key: str, *, async_submit: bool = False) -> dict:
"""Build DashScope auth headers. The async header is required on submit only."""
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
}
if async_submit:
headers["X-DashScope-Async"] = "enable"
return headers
async def _submit_and_wait(
base_url: str,
api_key: str,
body: dict,
poll_interval: int,
max_wait: int,
):
"""Submit a DashScope video task, poll until done, return the VIDEO output."""
base = base_url.rstrip("/")
submit_url = f"{base}{SUBMIT_PATH}"
submit_resp = await http_request(
"POST", submit_url, _auth_headers(api_key, async_submit=True), json_data=body
)
output = submit_resp.get("output") or {}
task_id = output.get("task_id")
if not task_id:
code = submit_resp.get("code")
message = submit_resp.get("message")
raise RuntimeError(
f"DashScope did not return a task_id. code={code} message={message} response={submit_resp}"
)
task_url = f"{base}{TASK_PATH.format(task_id=task_id)}"
poll_headers = _auth_headers(api_key)
async def _poll():
return await http_request("GET", task_url, poll_headers)
final = await poll_until(
_poll,
status_of=lambda r: (r.get("output") or {}).get("task_status"),
done_statuses=DONE_STATUSES,
failed_statuses=FAILED_STATUSES,
interval=poll_interval,
max_wait=max_wait,
on_fail_message="Wan video generation failed",
)
final_output = final.get("output") or {}
video_url = final_output.get("video_url")
if not video_url:
raise RuntimeError(f"Wan task succeeded but no video_url was returned: {final}")
# The download URL is authenticated on DashScope's CDN and does not need the
# API key, so download without auth headers.
return await download_video_output(video_url)
class BYOKey_Wan_Text2Video:
"""Wan text-to-video via Alibaba DashScope (bring-your-own-key).
Mirrors the official ``WanTextToVideoApi`` node. Output: VIDEO.
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"api_key": ("STRING", {
"default": "",
"tooltip": "DashScope (Alibaba Model Studio) API key.",
}),
"model": (["wan2.5-t2v-preview", "wan2.6-t2v"], {
"default": "wan2.6-t2v",
"tooltip": "Model to use.",
}),
"prompt": ("STRING", {
"multiline": True,
"default": "",
"tooltip": "Prompt describing the elements and visual features. "
"Supports English and Chinese.",
}),
},
"optional": {
"base_url": ("STRING", {
"default": DEFAULT_BASE_URL,
"tooltip": "DashScope API host. Use https://dashscope-intl.aliyuncs.com "
"for the international endpoint, or a relay base URL.",
}),
"negative_prompt": ("STRING", {
"multiline": True,
"default": "",
"tooltip": "Negative prompt describing what to avoid.",
}),
"size": (T2V_SIZE_OPTIONS, {
"default": "720p: 1:1 (960x960)",
"tooltip": "Video resolution and aspect ratio.",
}),
"duration": ("INT", {
"default": 5,
"min": 5,
"max": 15,
"step": 5,
"tooltip": "Video duration in seconds. A 15-second duration is "
"available only for the Wan 2.6 model.",
}),
"seed": ("INT", {"default": 0, "min": 0, "max": 2147483647}),
"generate_audio": ("BOOLEAN", {
"default": False,
"tooltip": "If no audio input is provided, generate audio automatically.",
}),
"prompt_extend": ("BOOLEAN", {
"default": True,
"tooltip": "Whether to enhance the prompt with AI assistance.",
}),
"watermark": ("BOOLEAN", {
"default": False,
"tooltip": "Whether to add an AI-generated watermark to the result.",
}),
"shot_type": (SHOT_TYPE_OPTIONS, {
"default": "single",
"tooltip": "Single continuous shot or multiple shots with cuts. "
"Takes effect only when prompt_extend is True.",
}),
"poll_interval": ("INT", {"default": 6, "min": 2, "max": 60}),
"max_wait": ("INT", {"default": 600, "min": 60, "max": 3600}),
},
}
RETURN_TYPES = ("VIDEO",)
RETURN_NAMES = ("output",)
FUNCTION = "execute"
CATEGORY = "api/byokey/video"
DESCRIPTION = "Wan text-to-video generation via Alibaba DashScope (bring your own key)."
def execute(
self,
api_key,
model,
prompt,
base_url=DEFAULT_BASE_URL,
negative_prompt="",
size="720p: 1:1 (960x960)",
duration=5,
seed=0,
generate_audio=False,
prompt_extend=True,
watermark=False,
shot_type="single",
poll_interval=6,
max_wait=600,
):
validate_prompt(prompt, min_length=1)
if "480p" in size and model == "wan2.6-t2v":
raise ValueError("The Wan 2.6 model does not support 480p.")
if duration == 15 and model == "wan2.5-t2v-preview":
raise ValueError("A 15-second duration is supported only by the Wan 2.6 model.")
size_value = _parse_size_from_label(size)
input_field = {"prompt": prompt}
if negative_prompt:
input_field["negative_prompt"] = negative_prompt
body = {
"model": model,
"input": input_field,
"parameters": {
"size": size_value,
"duration": duration,
"seed": seed,
"audio": generate_audio,
"prompt_extend": prompt_extend,
"watermark": watermark,
"shot_type": shot_type,
},
}
video = run_async(
_submit_and_wait(base_url, api_key, body, poll_interval, max_wait)
)
return (video,)
class BYOKey_Wan_Image2Video:
"""Wan image-to-video via Alibaba DashScope (bring-your-own-key).
Mirrors the official ``WanImageToVideoApi`` node. The input image is the first
frame; the output video's aspect ratio is derived from it. Output: VIDEO.
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"api_key": ("STRING", {
"default": "",
"tooltip": "DashScope (Alibaba Model Studio) API key.",
}),
"model": (["wan2.5-i2v-preview", "wan2.6-i2v"], {
"default": "wan2.6-i2v",
"tooltip": "Model to use.",
}),
"image": ("IMAGE", {
"tooltip": "Input image used as the first frame. Exactly one image is required.",
}),
"prompt": ("STRING", {
"multiline": True,
"default": "",
"tooltip": "Prompt describing the elements and visual features. "
"Supports English and Chinese.",
}),
},
"optional": {
"base_url": ("STRING", {
"default": DEFAULT_BASE_URL,
"tooltip": "DashScope API host. Use https://dashscope-intl.aliyuncs.com "
"for the international endpoint, or a relay base URL.",
}),
"negative_prompt": ("STRING", {
"multiline": True,
"default": "",
"tooltip": "Negative prompt describing what to avoid.",
}),
"resolution": (I2V_RESOLUTION_OPTIONS, {
"default": "720P",
"tooltip": "Video resolution quality. The Wan 2.6 model does not support 480P.",
}),
"duration": ("INT", {
"default": 5,
"min": 5,
"max": 15,
"step": 5,
"tooltip": "Video duration in seconds. A 15-second duration is "
"supported only by the Wan 2.6 model.",
}),
"seed": ("INT", {"default": 0, "min": 0, "max": 2147483647}),
"generate_audio": ("BOOLEAN", {
"default": False,
"tooltip": "If no audio input is provided, generate audio automatically.",
}),
"prompt_extend": ("BOOLEAN", {
"default": True,
"tooltip": "Whether to enhance the prompt with AI assistance.",
}),
"watermark": ("BOOLEAN", {
"default": False,
"tooltip": "Whether to add an AI-generated watermark to the result.",
}),
"shot_type": (SHOT_TYPE_OPTIONS, {
"default": "single",
"tooltip": "Single continuous shot or multiple shots with cuts. "
"Takes effect only when prompt_extend is True.",
}),
"poll_interval": ("INT", {"default": 6, "min": 2, "max": 60}),
"max_wait": ("INT", {"default": 600, "min": 60, "max": 3600}),
},
}
RETURN_TYPES = ("VIDEO",)
RETURN_NAMES = ("output",)
FUNCTION = "execute"
CATEGORY = "api/byokey/video"
DESCRIPTION = "Wan image-to-video generation via Alibaba DashScope (bring your own key)."
def execute(
self,
api_key,
model,
image,
prompt,
base_url=DEFAULT_BASE_URL,
negative_prompt="",
resolution="720P",
duration=5,
seed=0,
generate_audio=False,
prompt_extend=True,
watermark=False,
shot_type="single",
poll_interval=6,
max_wait=600,
):
validate_prompt(prompt, min_length=1)
if image.shape[0] != 1:
raise ValueError(
f"Exactly one input image is required, but got a batch of {image.shape[0]}."
)
if "480P" in resolution and model == "wan2.6-i2v":
raise ValueError("The Wan 2.6 model does not support 480P.")
if duration == 15 and model == "wan2.5-i2v-preview":
raise ValueError("A 15-second duration is supported only by the Wan 2.6 model.")
img_url = tensor_to_data_uri(image, mime_type="image/png")
input_field = {"prompt": prompt, "img_url": img_url}
if negative_prompt:
input_field["negative_prompt"] = negative_prompt
body = {
"model": model,
"input": input_field,
"parameters": {
"resolution": resolution,
"duration": duration,
"seed": seed,
"audio": generate_audio,
"prompt_extend": prompt_extend,
"watermark": watermark,
"shot_type": shot_type,
},
}
video = run_async(
_submit_and_wait(base_url, api_key, body, poll_interval, max_wait)
)
return (video,)