diff --git a/.gitignore b/.gitignore index f545cd1..8779f8d 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,5 @@ __pycache__ py/__pycache__ py/modelverse_api/__pycache__ py/modelverse_api/requests/__pycache__ +/venv +.idea/ \ No newline at end of file diff --git a/__init__.py b/__init__.py index 66e08d9..8ab029c 100644 --- a/__init__.py +++ b/__init__.py @@ -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 diff --git a/py/modelverse_api/client.py b/py/modelverse_api/client.py index 4fd8d50..7e44f9c 100644 --- a/py/modelverse_api/client.py +++ b/py/modelverse_api/client.py @@ -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): diff --git a/py/wan_ai_i2v.py b/py/wan_ai_i2v.py new file mode 100644 index 0000000..0809bfc --- /dev/null +++ b/py/wan_ai_i2v.py @@ -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" +} diff --git a/py/wan_ai_t2v.py b/py/wan_ai_t2v.py new file mode 100644 index 0000000..8d464d0 --- /dev/null +++ b/py/wan_ai_t2v.py @@ -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" +} +