add video async api

* add gitignore

* add video api

* fix: export to video

* fix

* fix

* fix

* fix

* fix

* add i2v

* fix

* fix

---------

Co-authored-by: jason.mei <jason.mei@ucloud.cn>
This commit is contained in:
jason
2025-09-09 13:52:07 +08:00
committed by GitHub
co-authored by jason.mei
parent cde31dc9a7
commit 7d350330e7
5 changed files with 365 additions and 44 deletions
+2
View File
@@ -3,3 +3,5 @@ __pycache__
py/__pycache__
py/modelverse_api/__pycache__
py/modelverse_api/requests/__pycache__
/venv
.idea/
+2 -1
View File
@@ -39,7 +39,8 @@ for file in files:
serialized_CLASS_MAPPINGS = {k: serialize(v) for k, v in imported_module.NODE_CLASS_MAPPINGS.items()}
serialized_DISPLAY_NAME_MAPPINGS = {k: serialize(v) for k, v in imported_module.NODE_DISPLAY_NAME_MAPPINGS.items()}
all_nodes[file]={"NODE_CLASS_MAPPINGS": serialized_CLASS_MAPPINGS, "NODE_DISPLAY_NAME_MAPPINGS": serialized_DISPLAY_NAME_MAPPINGS}
except:
except Exception as e:
print(f"Failed to import {file}: {e}")
pass
+38 -43
View File
@@ -4,47 +4,35 @@ import asyncio
from .utils import BaseRequest
import asyncio
from .utils import BaseRequest
class ModelverseClient:
"""
UCloud Modelverse API Client
This class handles the core communication with the Modelverse API.
"""
BASE_URL = "https://api.modelverse.cn"
API_PATH = "/v1/images/generations"
def __init__(self, api_key):
"""
Initialize Modelverse API client
Args:
api_key (str): Modelverse API key
"""
self.api_key = api_key
self.headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json"
}
self.headers = {"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json"}
def post(self, endpoint, payload, timeout=180):
url = f"{self.BASE_URL}{endpoint}"
response = requests.post(url, headers=self.headers, json=payload, timeout=timeout)
return self._handle_response(response)
def post(self, payload, timeout=180):
"""
Send POST request to Modelverse API
Args:
endpoint (str): API endpoint
payload (dict): Request payload
timeout (float, optional): Request timeout in seconds
Returns:
dict: API response
"""
url = f"{self.BASE_URL}{self.API_PATH}"
response = requests.post(
url, headers=self.headers, json=payload, timeout=timeout)
def get(self, endpoint, params=None, timeout=180):
url = f"{self.BASE_URL}{endpoint}"
headers = {"Authorization": f"Bearer {self.api_key}"}
response = requests.get(url, headers=headers, params=params, timeout=timeout)
return self._handle_response(response)
def _handle_response(self, response):
if response.status_code == 401:
raise Exception("Unauthorized: Invalid API key")
# For backward compatibility with older error formats
if response.status_code != 200:
error_message = f"Error: {response.status_code}"
try:
@@ -60,26 +48,33 @@ class ModelverseClient:
if response_data['code'] == 401:
raise Exception("Unauthorized: Invalid API key")
if response_data['code'] != 200:
raise Exception(
f"API Error: {response_data.get('message', 'Unknown error')}")
raise Exception(f"API Error: {response_data.get('message', 'Unknown error')}")
return response_data.get('data', {})
return response_data
# --- New Methods for T2V ---
def submit_task(self, model, task_input, parameters):
endpoint = "/v1/tasks/submit"
payload = {
"model": model,
"input": task_input,
"parameters": parameters
}
return self.post(endpoint, payload)
def get_task_status(self, task_id):
endpoint = f"/v1/tasks/status"
params = {"task_id": task_id}
return self.get(endpoint, params=params)
# --- Restored Async Methods for existing nodes ---
async def async_send_request(self, request: BaseRequest):
"""
Sends an API request using a request object.
Args:
request (BaseRequest): The request object containing payload and endpoint logic.
Returns:
dict: API response or task result.
"""
payload = request.build_payload()
endpoint = request.API_PATH
if "seed" in payload:
payload["seed"] = payload["seed"] % 2147483647 if payload["seed"] != -1 else -1
response = self.post(payload)
response = self.post(endpoint, payload)
return response.get("data", [])
async def run_tasks(self, tasks):
+200
View File
@@ -0,0 +1,200 @@
import time
import requests
import os
import tempfile
import io
from .modelverse_api.client import ModelverseClient
from .modelverse_api.utils import image_to_base64
# Import VideoFromFile class for ComfyUI video handling
try:
# Try the newer import path first
from comfy_extras.nodes_video import VideoFromFile
except ImportError:
try:
# Fallback to older import path
from comfy.model_management import VideoFromFile
except ImportError:
# Final fallback - create a simple wrapper
class VideoFromFile:
def __init__(self, video_io):
self.video_io = video_io
self.video_data = video_io.getvalue() if hasattr(video_io, 'getvalue') else video_io
def get_dimensions(self):
# Return default dimensions if we can't determine them
return (1280, 720) # width, height
class Modelverse_WanAII2V:
def __init__(self):
pass
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"client": ("MODELVERSE_API_CLIENT",),
"prompt": ("STRING", {"multiline": True, "default": "Convert to video","tooltip": "Text prompt to guide video generation"}),
},
"optional": {
"first_frame_image": ("IMAGE",),
"first_frame_url": ("STRING", {"default": "", "tooltip": "First frame image URL (use either this OR first_frame_image, not both)"}),
"last_frame_image": ("IMAGE",),
"last_frame_url": ("STRING", {"default": "", "tooltip": "Optional: URL for the last frame of the video"}),
"negative_prompt": ("STRING", {"multiline": True, "default": "low quality, blurry","tooltip": "Negative prompt to avoid unwanted content"}),
"resolution": (["720P", "480P"], {"default": "720P", "tooltip": "Output video resolution"}),
"seed": ("INT", {"default": 0, "min": 0, "max": 2147483647, "tooltip": "Random seed for reproducible results"}),
}
}
RETURN_TYPES = ("VIDEO",)
RETURN_NAMES = ("video",)
FUNCTION = "generate_video"
CATEGORY = "UCLOUD_MODELVERSE"
def generate_video(self, client, prompt, first_frame_image=None, first_frame_url="", last_frame_image=None, last_frame_url="", negative_prompt="", resolution="720P", seed=0):
api_key = client.get("api_key")
if not api_key:
raise ValueError("API key is not set in the client")
mv_client = ModelverseClient(api_key)
# Prepare the input data
task_input = {"prompt": prompt}
# Validate first frame input - must provide either image or URL, but not both
has_url = first_frame_url and first_frame_url.strip()
has_image = first_frame_image is not None
if has_url and has_image:
raise ValueError("Please provide either first_frame_image OR first_frame_url, not both")
elif not has_url and not has_image:
raise ValueError("Must provide either first_frame_image or first_frame_url")
# Handle first frame
if has_url:
task_input["first_frame_url"] = first_frame_url.strip()
print(f"Using first frame URL: {first_frame_url}")
else:
# Convert IMAGE tensor to base64
first_frame_base64 = image_to_base64(first_frame_image)
if not first_frame_base64:
raise ValueError("Failed to convert first frame image to base64")
task_input["first_frame_url"] = first_frame_base64
print("Using first frame from IMAGE input (converted to base64)")
# Handle last frame (optional)
if last_frame_url and last_frame_url.strip():
task_input["last_frame_url"] = last_frame_url.strip()
print(f"Using last frame URL: {last_frame_url}")
elif last_frame_image is not None:
# Convert IMAGE tensor to base64
last_frame_base64 = image_to_base64(last_frame_image)
if last_frame_base64:
task_input["last_frame_url"] = last_frame_base64
print("Using last frame from IMAGE input (converted to base64)")
# Add negative prompt if provided
if negative_prompt and negative_prompt.strip():
task_input["negative_prompt"] = negative_prompt.strip()
# Set parameters
parameters = {
"resolution": resolution,
"duration": 5, # Fixed as per documentation
"seed": seed,
}
print(f"Submitting I2V task with model: Wan-AI/Wan2.2-I2V")
print(f"Parameters: resolution={resolution}, seed={seed}")
# 1. Submit the task
submit_res = mv_client.submit_task("Wan-AI/Wan2.2-I2V", task_input, parameters)
task_id = submit_res.get("output", {}).get("task_id")
if not task_id:
raise Exception(f"Failed to submit task: {submit_res.get('request_id')}")
print(f"Task submitted successfully with ID: {task_id}")
# 2. Poll for the result
video_url = ""
max_retries = 120 # Maximum 10 minutes (120 * 5 seconds)
retry_count = 0
while True:
try:
status_res = mv_client.get_task_status(task_id)
task_status = status_res.get("output", {}).get("task_status")
if task_status == "Success":
urls = status_res.get("output", {}).get("urls", [])
if urls and len(urls) > 0:
video_url = urls[0]
print(f"Task completed successfully! Video URL: {video_url}")
break
else:
raise Exception("Task succeeded but no video URL was returned.")
elif task_status == "Failure":
error_message = status_res.get("output", {}).get("error_message", "Unknown error")
raise Exception(f"Task failed: {error_message}")
elif task_status in ["Pending", "Running"]:
print(f"Task {task_id} is {task_status}, waiting... ({retry_count + 1}/{max_retries})")
time.sleep(5) # Wait for 5 seconds before polling again
retry_count += 1
else:
raise Exception(f"Unknown task status: {task_status}")
except Exception as e:
if "Task failed" in str(e) or "Unknown task status" in str(e):
raise e
print(f"Error checking task status: {e}, retrying...")
retry_count += 1
time.sleep(5)
if not video_url:
raise Exception(f"Task timed out after {max_retries * 5} seconds")
# 3. Download video
print(f"Downloading video from: {video_url}")
try:
response = requests.get(video_url, timeout=60)
response.raise_for_status()
except requests.exceptions.RequestException as e:
raise Exception(f"Failed to download video: {e}")
# Extract video data
video_data = response.content
if not video_data:
raise Exception("No video data was returned")
print("Video generation completed successfully")
print(f"Downloaded video data size: {len(video_data)} bytes")
# Convert video data to BytesIO object
video_io = io.BytesIO(video_data)
# Ensure the BytesIO object is at the beginning
video_io.seek(0)
# Return VideoFromFile object
try:
video_obj = VideoFromFile(video_io)
print("VideoFromFile object created successfully")
return (video_obj,)
except Exception as e:
print(f"Error creating VideoFromFile object: {e}")
# If VideoFromFile fails, try returning the raw video data
# This is a fallback that might work with some ComfyUI versions
return (video_data,)
NODE_CLASS_MAPPINGS = {
"Modelverse_WanAII2V": Modelverse_WanAII2V
}
NODE_DISPLAY_NAME_MAPPINGS = {
"Modelverse_WanAII2V": "Modelverse Wan-AI I2V"
}
+123
View File
@@ -0,0 +1,123 @@
import time
import requests
import os
import tempfile
import io
from .modelverse_api.client import ModelverseClient
# Import VideoFromFile class for ComfyUI video handling
try:
# Try the newer import path first
from comfy_extras.nodes_video import VideoFromFile
except ImportError:
try:
# Fallback to older import path
from comfy.model_management import VideoFromFile
except:
raise ImportError("VideoFromFile not found")
class Modelverse_WanAIT2V:
def __init__(self):
pass
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"client": ("MODELVERSE_API_CLIENT",),
"prompt": ("STRING", {"multiline": True, "default": "a beautiful flower","tooltip": "Text prompt of the image to generate"}),
"negative_prompt": ("STRING", {"multiline": True, "default": "low quality","tooltip": "Negative prompt of the image to generate"}),
"resolution": (["720P", "480P"], {"default": "720P"}),
"size": (["1280x720", "720x1280", "832x480", "480x832"], {"default": "1280x720"}),
"seed": ("INT", {"default": 0, "min": 0, "max": 2147483647}),
}
}
RETURN_TYPES = ("VIDEO",)
RETURN_NAMES = ("video",)
FUNCTION = "generate_video"
CATEGORY = "UCLOUD_MODELVERSE"
def generate_video(self, client, prompt, negative_prompt, resolution, size, seed):
api_key = client.get("api_key")
if not api_key:
raise ValueError("API key is not set in the client")
mv_client = ModelverseClient(api_key)
task_input = {"prompt": prompt}
if negative_prompt:
task_input["negative_prompt"] = negative_prompt
parameters = {
"resolution": resolution,
"size": size,
"seed": seed,
"duration": 5 # Fixed as per documentation
}
# 1. Submit the task
submit_res = mv_client.submit_task("Wan-AI/Wan2.2-T2V", task_input, parameters)
task_id = submit_res.get("output", {}).get("task_id")
if not task_id:
raise Exception(f"Failed to submit task: {submit_res.get('request_id')}")
# 2. Poll for the result
video_url = ""
while True:
status_res = mv_client.get_task_status(task_id)
task_status = status_res.get("output", {}).get("task_status")
if task_status == "Success":
video_url = status_res.get("output", {}).get("urls", [None])[0]
if not video_url:
raise Exception("Task succeeded but no video URL was returned.")
break
elif task_status == "Failure":
error_message = status_res.get("output", {}).get("error_message", "Unknown error")
raise Exception(f"Task failed: {error_message}")
elif task_status in ["Pending", "Running"]:
print(f"Task {task_id} is {task_status}, waiting...")
time.sleep(5) # Wait for 5 seconds before polling again
else:
raise Exception(f"Unknown task status: {task_status}")
# 3. Download video
response = requests.get(video_url)
response.raise_for_status()
# Extract video data
video_data = response.content
if not video_data:
raise Exception("No video data was returned")
print("Video generation completed successfully")
print(f"Downloaded video data size: {len(video_data)} bytes")
# Convert video data to BytesIO object
video_io = io.BytesIO(video_data)
# Ensure the BytesIO object is at the beginning
video_io.seek(0)
# Return VideoFromFile object
try:
video_obj = VideoFromFile(video_io)
print("VideoFromFile object created successfully")
return (video_obj,)
except Exception as e:
print(f"Error creating VideoFromFile object: {e}")
# If VideoFromFile fails, try returning the raw video data
# This is a fallback that might work with some ComfyUI versions
return (video_data,)
NODE_CLASS_MAPPINGS = {
"Modelverse_WanAIT2V": Modelverse_WanAIT2V
}
NODE_DISPLAY_NAME_MAPPINGS = {
"Modelverse_WanAIT2V": "Modelverse Wan-AI T2V"
}