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

181 lines
6.8 KiB
Python

"""PixVerse text-to-video node (bring-your-own-key).
Faithfully mirrors the official ComfyUI ``PixverseTextToVideoNode`` request /
response contract, but calls the real PixVerse OpenAPI directly instead of going
through the ComfyOrg proxy.
Ground truth:
* Official node: comfy_api_nodes/nodes_pixverse.py (PixverseTextToVideoNode)
* Official models: comfy_api_nodes/apis/pixverse.py
* Vendor API host: https://app-api.pixverse.ai/openapi/v2
- submit: POST /video/text/generate (body == PixverseTextVideoRequest)
- poll: GET /video/result/{video_id}
- headers: API-KEY, Ai-trace-id (unique uuid per request), Content-Type
- status (int): 1 successful, 5 generating, 6 deleted,
7 contents_moderation, 8 failed
The ComfyOrg proxy forwards the official request body verbatim to this vendor
API, so the official models ARE the real vendor request/response shapes.
"""
import uuid
from .utils import (
download_video_output,
http_request,
poll_until,
run_async,
validate_prompt,
)
# Real PixVerse OpenAPI host (overridable for relays).
PIXVERSE_API_BASE = "https://app-api.pixverse.ai/openapi/v2"
# COMBO option lists (match official define_schema + embedded docs).
ASPECT_RATIOS = ["16:9", "4:3", "1:1", "3:4", "9:16"]
QUALITIES = ["360p", "540p", "720p", "1080p"]
DURATIONS = [5, 8]
MOTION_MODES = ["normal", "fast"]
# Vendor status codes (PixverseStatus enum, ints).
STATUS_SUCCESSFUL = 1
STATUS_GENERATING = 5
STATUS_DELETED = 6
STATUS_CONTENTS_MODERATION = 7
STATUS_FAILED = 8
class BYOKey_PixVerse_Text2Video:
"""PixVerse text-to-video. Output: VIDEO.
Mirrors official ``PixverseTextToVideoNode``: submit a text prompt with
aspect ratio / quality / duration / motion mode, poll the result endpoint,
and download the finished mp4 as a ComfyUI VIDEO object.
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"api_key": ("STRING", {"default": "", "tooltip": "Your PixVerse API key (sent as the API-KEY header)."}),
"base_url": ("STRING", {"default": PIXVERSE_API_BASE, "tooltip": "PixVerse OpenAPI base URL; override for a relay."}),
"prompt": ("STRING", {"multiline": True, "default": "", "tooltip": "Prompt for the video generation."}),
"aspect_ratio": (ASPECT_RATIOS, {"default": "16:9"}),
"quality": (QUALITIES, {"default": "540p"}),
"duration_seconds": (DURATIONS, {"default": 5}),
"motion_mode": (MOTION_MODES, {"default": "normal"}),
"seed": ("INT", {"default": 0, "min": 0, "max": 2147483647, "control_after_generate": True, "tooltip": "Seed for video generation."}),
},
"optional": {
"negative_prompt": ("STRING", {"multiline": True, "default": "", "tooltip": "An optional text description of undesired elements."}),
"model": ("STRING", {"default": "v3.5", "tooltip": "PixVerse model version (e.g. v3.5)."}),
"template_id": ("INT", {"default": 0, "min": 0, "max": 2147483647, "tooltip": "Optional PixVerse template id (0 = none)."}),
"poll_interval": ("INT", {"default": 10, "min": 1, "max": 60, "tooltip": "Seconds between status polls."}),
"max_wait": ("INT", {"default": 600, "min": 30, "max": 3600, "tooltip": "Max seconds to wait for the video."}),
},
}
RETURN_TYPES = ("VIDEO",)
RETURN_NAMES = ("output",)
FUNCTION = "execute"
CATEGORY = "api/byokey/video"
DESCRIPTION = "Generates videos based on prompt and output_size (PixVerse, bring your own key)."
def execute(
self,
api_key,
base_url,
prompt,
aspect_ratio,
quality,
duration_seconds,
motion_mode,
seed,
negative_prompt="",
model="v3.5",
template_id=0,
poll_interval=10,
max_wait=600,
):
validate_prompt(prompt, min_length=1)
# Official constraints:
# 1080p is limited to 5 seconds duration and only normal motion mode.
# Only normal motion mode is supported for non-5-second durations.
if quality == "1080p":
motion_mode = "normal"
duration_seconds = 5
elif duration_seconds != 5:
motion_mode = "normal"
base = base_url.rstrip("/")
# Request body == PixverseTextVideoRequest (only set optional fields when present).
body = {
"aspect_ratio": aspect_ratio,
"quality": quality,
"duration": int(duration_seconds),
"model": model,
"motion_mode": motion_mode,
"prompt": prompt,
}
if negative_prompt:
body["negative_prompt"] = negative_prompt
if seed:
body["seed"] = int(seed)
if template_id:
body["template_id"] = int(template_id)
video = run_async(self._generate(base, api_key, body, poll_interval, max_wait))
return (video,)
@staticmethod
def _headers(api_key):
# PixVerse OpenAPI auth: API-KEY plus a unique Ai-trace-id per request.
return {
"API-KEY": api_key,
"Ai-trace-id": str(uuid.uuid4()),
"Content-Type": "application/json",
}
async def _generate(self, base, api_key, body, poll_interval, max_wait):
# Submit generation (response == PixverseVideoResponse: {ErrCode, ErrMsg, Resp:{video_id}}).
submit = await http_request(
"POST",
f"{base}/video/text/generate",
self._headers(api_key),
json_data=body,
)
resp = submit.get("Resp") or {}
video_id = resp.get("video_id")
if not video_id:
raise RuntimeError(
f"PixVerse request failed: '{submit.get('ErrMsg')}' (ErrCode={submit.get('ErrCode')}). Response={submit}"
)
# Poll result (response == PixverseGenerationStatusResponse: {..., Resp:{status, url}}).
async def poll_fn():
return await http_request(
"GET",
f"{base}/video/result/{video_id}",
self._headers(api_key),
)
final = await poll_until(
poll_fn,
status_of=lambda x: (x.get("Resp") or {}).get("status"),
done_statuses=[str(STATUS_SUCCESSFUL)],
failed_statuses=[
str(STATUS_CONTENTS_MODERATION),
str(STATUS_FAILED),
str(STATUS_DELETED),
],
interval=poll_interval,
max_wait=max_wait,
on_fail_message="PixVerse generation failed",
)
url = (final.get("Resp") or {}).get("url")
if not url:
raise RuntimeError(f"PixVerse returned no video URL. Response={final}")
return await download_video_output(url)