From bd7d00b1ce433ed8945186466145e653ae73f46c Mon Sep 17 00:00:00 2001 From: "jason.mei" Date: Mon, 29 Dec 2025 16:03:39 +0800 Subject: [PATCH 01/12] feat(modelverse): add Gemini Pro Image and Vidu video generation support - Add Gemini 3 Pro Image Preview (Nano Banana Pro) ComfyUI node with high-resolution output (1K, 2K, 4K) - Add GeminiProImageRequest builder supporting aspect ratio control, Google Search grounding, and up to 14 reference images - Add Vidu video generation nodes: t2v, i2v, ref2v, and startend2v for comprehensive video synthesis - Add Vidu extend node for video continuation and frame extension - Update .gitignore to exclude .ace-tool/ and docs/ directories - Enable professional asset production with advanced image and video generation capabilities --- .gitignore | 4 +- py/gemini_pro_image.py | 149 ++++++++++++++++ .../requests/gemini_pro_image.py | 121 +++++++++++++ py/vidu_extend.py | 141 +++++++++++++++ py/vidu_i2v.py | 143 +++++++++++++++ py/vidu_ref2v.py | 157 +++++++++++++++++ py/vidu_startend2v.py | 163 ++++++++++++++++++ py/vidu_t2v.py | 122 +++++++++++++ 8 files changed, 999 insertions(+), 1 deletion(-) create mode 100644 py/gemini_pro_image.py create mode 100644 py/modelverse_api/requests/gemini_pro_image.py create mode 100644 py/vidu_extend.py create mode 100644 py/vidu_i2v.py create mode 100644 py/vidu_ref2v.py create mode 100644 py/vidu_startend2v.py create mode 100644 py/vidu_t2v.py diff --git a/.gitignore b/.gitignore index 8779f8d..278884e 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,6 @@ py/__pycache__ py/modelverse_api/__pycache__ py/modelverse_api/requests/__pycache__ /venv -.idea/ \ No newline at end of file +.idea/ +.ace-tool/ +docs \ No newline at end of file diff --git a/py/gemini_pro_image.py b/py/gemini_pro_image.py new file mode 100644 index 0000000..f5cbffb --- /dev/null +++ b/py/gemini_pro_image.py @@ -0,0 +1,149 @@ +""" +Gemini 3 Pro Image Preview (Nano Banana Pro) ComfyUI Node. +Professional asset production with advanced features: +- High-resolution output (1K, 2K, 4K) +- Aspect ratio control +- Google Search grounding +- Up to 14 reference images +""" +import base64 +import torch +from typing import Optional, List, Dict, Any +from comfy.comfy_types.node_typing import IO + +from .modelverse_api.client import ModelverseClient +from .modelverse_api.requests.gemini_pro_image import GeminiProImageRequest +from .modelverse_api.utils import decode_image, images2tensor + + +def _extract_images_from_gemini_response(resp: Dict[str, Any]) -> List[torch.Tensor]: + """Extract image tensors from Gemini API response.""" + tensors: List[torch.Tensor] = [] + candidates = resp.get("candidates", []) + for cand in candidates: + content = cand.get("content", {}) + parts = content.get("parts", []) + for part in parts: + inline = part.get("inlineData") + if inline: + data_b64 = inline.get("data") + if data_b64: + raw = base64.b64decode(data_b64) + pil_img = decode_image(raw) + tensors.append(images2tensor(pil_img)) + # Also print any text response + if part.get("text"): + print("INFO: Gemini Pro Image text response:", part.get("text")) + return tensors + + +# Aspect ratio options +ASPECT_RATIOS = ["auto", "1:1", "2:3", "3:2", "3:4", "4:3", "4:5", "5:4", "9:16", "16:9", "21:9"] +# Resolution options +IMAGE_SIZES = ["1K", "2K", "4K"] + + +class GeminiProImageNode: + """ + Gemini 3 Pro Image Preview (Nano Banana Pro) via Modelverse API. + + Professional asset production with: + - High-resolution output (1K, 2K, 4K) + - Aspect ratio control + - Google Search grounding for real-time info + - Up to 14 reference images + + Endpoint: /v1beta/models/gemini-3-pro-image-preview:generateContent + """ + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "client": ("MODELVERSE_API_CLIENT",), + "prompt": (IO.STRING, {"multiline": True, "default": "Create a professional product photo"}), + "mime_type": (["image/png", "image/jpeg"], {"default": "image/png"}), + "aspect_ratio": (ASPECT_RATIOS, {"default": "auto"}), + "image_size": (IMAGE_SIZES, {"default": "1K"}), + "use_google_search": (IO.BOOLEAN, {"default": False, "tooltip": "Enable Google Search grounding for real-time info"}), + "num_requests": (IO.INT, {"default": 1, "min": 1, "max": 10, "step": 1, "display": "number"}), + }, + "optional": { + "image1": (IO.IMAGE, {"default": None, "tooltip": "Reference image 1"}), + "image2": (IO.IMAGE, {"default": None, "tooltip": "Reference image 2"}), + "image3": (IO.IMAGE, {"default": None, "tooltip": "Reference image 3"}), + "image4": (IO.IMAGE, {"default": None, "tooltip": "Reference image 4"}), + "image5": (IO.IMAGE, {"default": None, "tooltip": "Reference image 5"}), + }, + } + + RETURN_TYPES = (IO.IMAGE,) + RETURN_NAMES = ("image",) + CATEGORY = "UCLOUD_MODELVERSE" + FUNCTION = "execute" + + async def execute( + self, + client, + prompt: str, + mime_type: str = "image/png", + aspect_ratio: str = "auto", + image_size: str = "1K", + use_google_search: bool = False, + num_requests: int = 1, + image1=None, + image2=None, + image3=None, + image4=None, + image5=None, + ): + if not prompt: + raise ValueError("Prompt is required") + + mv_client = ModelverseClient(client["api_key"]) + + # Collect input images + images = [] + for img in [image1, image2, image3, image4, image5]: + if img is not None: + images.append(img) + + # Process aspect_ratio + ar = aspect_ratio if aspect_ratio != "auto" else None + + outputs: List[torch.Tensor] = [] + for i in range(num_requests): + req = GeminiProImageRequest( + prompt=prompt, + images=images if images else None, + mime_type=mime_type, + aspect_ratio=ar, + image_size=image_size, + use_google_search=use_google_search, + ) + payload = req.build_payload() + resp = mv_client.post(req.API_PATH, payload) + + if isinstance(resp, dict) and resp.get("error"): + err = resp.get("error") + raise Exception(f"GeminiProImage error: {err.get('message', 'Unknown error')}") + + tensors = _extract_images_from_gemini_response(resp) + if not tensors: + print("WARN:", "No image data found in Gemini Pro response; check console for details.") + continue + outputs.append(tensors[0]) + + if not outputs: + raise Exception("No images generated from Gemini 3 Pro Image Preview") + + return (torch.cat(outputs, dim=0),) + + +NODE_CLASS_MAPPINGS = { + 'Gemini Pro Image (Nano Banana Pro)': GeminiProImageNode, +} + +NODE_DISPLAY_NAME_MAPPINGS = { + 'Gemini Pro Image (Nano Banana Pro)': 'Gemini 3 Pro Image Preview', +} diff --git a/py/modelverse_api/requests/gemini_pro_image.py b/py/modelverse_api/requests/gemini_pro_image.py new file mode 100644 index 0000000..68506a2 --- /dev/null +++ b/py/modelverse_api/requests/gemini_pro_image.py @@ -0,0 +1,121 @@ +""" +Gemini 3 Pro Image Preview (Nano Banana Pro) request builder. +Supports text-to-image and image editing with advanced features like +aspect ratio, resolution control, and Google Search grounding. +""" +import io +import base64 +import numpy as np +from PIL import Image +from typing import Optional, List, Dict, Any +from pydantic import Field +from torch import Tensor + +from ..utils import BaseRequest + + +def _tensor_to_base64(image: Tensor, mime_type: str = "image/png") -> Dict[str, str]: + """Convert a ComfyUI image tensor (HWC or 4D with batch) to base64 without data URI.""" + if image is None: + return None + + # If batch, take first frame + if hasattr(image, 'shape') and len(image.shape) == 4: + image = image[0] + + # Tensor (H, W, C) in 0..1 -> uint8 + np_img = np.clip(255.0 * image.cpu().numpy(), 0, 255).astype(np.uint8) + pil_img = Image.fromarray(np_img) + + fmt = 'PNG' if mime_type.lower().endswith('png') else 'JPEG' + with io.BytesIO() as bio: + pil_img.save(bio, format=fmt) + data = bio.getvalue() + + return { + "mimeType": mime_type, + "data": base64.b64encode(data).decode("utf-8"), + } + + +class GeminiProImageRequest(BaseRequest): + """ + Request builder for Gemini 3 Pro Image Preview (Nano Banana Pro) generateContent endpoint. + Supports text-to-image, image editing, and advanced features like aspect ratio, + resolution control (1K/2K/4K), and Google Search grounding. + """ + + API_PATH = "/v1beta/models/gemini-3-pro-image-preview:generateContent" + + prompt: str = Field(..., description="Text prompt") + images: Optional[List[Tensor]] = Field(default=None, description="Optional input images (up to 14)") + mime_type: str = Field(default="image/png", description="MIME type for inline image data") + aspect_ratio: Optional[str] = Field(default=None, description="Aspect ratio: 1:1, 2:3, 3:2, 3:4, 4:3, 4:5, 5:4, 9:16, 16:9, 21:9") + image_size: Optional[str] = Field(default=None, description="Image resolution: 1K, 2K, 4K") + use_google_search: bool = Field(default=False, description="Enable Google Search grounding") + + def __init__( + self, + prompt: str, + images: Optional[List[Tensor]] = None, + mime_type: str = "image/png", + aspect_ratio: Optional[str] = None, + image_size: Optional[str] = None, + use_google_search: bool = False, + **kwargs + ): + super().__init__(**kwargs) + self.prompt = prompt + self.images = images + self.mime_type = mime_type + self.aspect_ratio = aspect_ratio + self.image_size = image_size + self.use_google_search = use_google_search + + def build_payload(self) -> Dict[str, Any]: + parts: List[Dict[str, Any]] = [] + + # Add text prompt + if self.prompt: + parts.append({"text": self.prompt}) + + # Add images (up to 14) + if self.images: + for img in self.images[:14]: # Limit to 14 images + if isinstance(img, Tensor): + parts.append({ + "inlineData": _tensor_to_base64(img, self.mime_type) + }) + + payload = { + "contents": [ + { + "role": "user", + "parts": parts, + } + ], + "generationConfig": { + "responseModalities": ["TEXT", "IMAGE"], + }, + } + + # Add imageConfig if aspect_ratio or image_size specified + if self.aspect_ratio or self.image_size: + image_config = {} + if self.aspect_ratio: + image_config["aspectRatio"] = self.aspect_ratio + if self.image_size: + image_config["imageSize"] = self.image_size + payload["generationConfig"]["imageConfig"] = image_config + + # Add Google Search tool if enabled + if self.use_google_search: + payload["tools"] = [{"google_search": {}}] + + return self._remove_empty_fields(payload) + + def field_required(self): + return ["prompt"] + + def field_order(self): + return ["prompt", "images", "mime_type", "aspect_ratio", "image_size", "use_google_search"] diff --git a/py/vidu_extend.py b/py/vidu_extend.py new file mode 100644 index 0000000..9e094c0 --- /dev/null +++ b/py/vidu_extend.py @@ -0,0 +1,141 @@ +""" +Vidu Extend - 视频延长模型 +Models: viduq2-pro, viduq2-turbo +""" +import time +import requests +import io +from .modelverse_api.client import ModelverseClient +from .modelverse_api.utils import image_to_base64 +from comfy.comfy_types.node_typing import IO + +try: + from comfy_extras.nodes_video import VideoFromFile +except ImportError: + try: + from comfy.model_management import VideoFromFile + except ImportError: + 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 + + +MODELS = ["viduq2-pro", "viduq2-turbo"] +RESOLUTIONS = ["540p", "720p", "1080p"] + + +class ViduExtendNode: + """ + Vidu Extend - 视频延长 + Models: viduq2-pro, viduq2-turbo + 输入视频时长需4秒-1分钟,延长1-7秒 + """ + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "client": ("MODELVERSE_API_CLIENT",), + "model": (MODELS, {"default": "viduq2-turbo", "tooltip": "viduq2-pro: 效果好, viduq2-turbo: 生成快"}), + "video_url": (IO.STRING, {"default": "", "tooltip": "需要延长的视频URL (4秒-1分钟)"}), + "duration": (IO.INT, {"default": 5, "min": 1, "max": 7, "step": 1, "tooltip": "延长时长(秒), 1-7"}), + "resolution": (RESOLUTIONS, {"default": "720p", "tooltip": "分辨率"}), + }, + "optional": { + "last_frame_image": (IO.IMAGE, {"tooltip": "延长到尾帧的参考图像"}), + "last_frame_url": (IO.STRING, {"default": "", "tooltip": "延长到尾帧的参考图像URL"}), + "prompt": (IO.STRING, {"multiline": True, "default": "", "tooltip": "延长提示词,控制延长内容"}), + } + } + + RETURN_TYPES = (IO.VIDEO,) + RETURN_NAMES = ("video",) + FUNCTION = "generate" + CATEGORY = "UCLOUD_MODELVERSE/Vidu" + + def generate(self, client, model, video_url, duration, resolution, + last_frame_image=None, last_frame_url="", prompt=""): + api_key = client.get("api_key") + if not api_key: + raise ValueError("API key is not set") + + if not video_url or not video_url.strip(): + raise ValueError("必须提供 video_url") + + mv_client = ModelverseClient(api_key) + + task_input = { + "video_url": video_url.strip(), + } + + # Optional last frame + has_last_url = last_frame_url and last_frame_url.strip() + has_last_image = last_frame_image is not None + if has_last_url and has_last_image: + raise ValueError("尾帧:请提供 image 或 url,不能同时提供") + + if has_last_url: + task_input["last_frame_url"] = last_frame_url.strip() + elif has_last_image: + task_input["last_frame_url"] = image_to_base64(last_frame_image) + + if prompt and prompt.strip(): + task_input["prompt"] = prompt.strip() + + parameters = { + "vidu_type": "extend", + "duration": duration, + "resolution": resolution, + } + + # Submit task + submit_res = mv_client.submit_task(model, 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}") + + print(f"Vidu Extend task submitted: {task_id}") + + # Poll for result + video_url_result = self._poll_task(mv_client, task_id) + + # Download and return video + return self._download_video(video_url_result) + + def _poll_task(self, mv_client, task_id, max_retries=180): + for i in range(max_retries): + 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: + return urls[0] + raise Exception("Task succeeded but no video URL returned") + elif task_status == "Failure": + error = status_res.get("output", {}).get("error_message", "Unknown error") + raise Exception(f"Task failed: {error}") + elif task_status in ["Pending", "Running"]: + print(f"Task {task_id}: {task_status} ({i+1}/{max_retries})") + time.sleep(5) + else: + raise Exception(f"Unknown status: {task_status}") + + raise Exception("Task timed out") + + def _download_video(self, video_url): + response = requests.get(video_url, timeout=120) + response.raise_for_status() + video_io = io.BytesIO(response.content) + video_io.seek(0) + return (VideoFromFile(video_io),) + + +NODE_CLASS_MAPPINGS = { + "Vidu_Extend": ViduExtendNode, +} + +NODE_DISPLAY_NAME_MAPPINGS = { + "Vidu_Extend": "Vidu Extend", +} diff --git a/py/vidu_i2v.py b/py/vidu_i2v.py new file mode 100644 index 0000000..5afe8dc --- /dev/null +++ b/py/vidu_i2v.py @@ -0,0 +1,143 @@ +""" +Vidu Img2Video - 图生视频模型 +Models: viduq2-pro, viduq2-turbo +""" +import time +import requests +import io +from .modelverse_api.client import ModelverseClient +from .modelverse_api.utils import image_to_base64 +from comfy.comfy_types.node_typing import IO + +try: + from comfy_extras.nodes_video import VideoFromFile +except ImportError: + try: + from comfy.model_management import VideoFromFile + except ImportError: + 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 + + +MODELS = ["viduq2-pro", "viduq2-turbo"] +RESOLUTIONS = ["540p", "720p", "1080p"] +MOVEMENT_AMPLITUDES = ["auto", "small", "medium", "large"] + + +class ViduImg2VideoNode: + """ + Vidu Img2Video - 图生视频 + Models: viduq2-pro (效果好,细节丰富), viduq2-turbo (效果好,生成快) + """ + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "client": ("MODELVERSE_API_CLIENT",), + "model": (MODELS, {"default": "viduq2-pro", "tooltip": "viduq2-pro: 效果好细节丰富, viduq2-turbo: 生成快"}), + "duration": (IO.INT, {"default": 5, "min": 1, "max": 10, "step": 1, "tooltip": "视频时长(秒)"}), + "resolution": (RESOLUTIONS, {"default": "720p", "tooltip": "分辨率"}), + "movement_amplitude": (MOVEMENT_AMPLITUDES, {"default": "auto", "tooltip": "运动幅度"}), + }, + "optional": { + "first_frame_image": (IO.IMAGE, {"tooltip": "首帧图片"}), + "first_frame_url": (IO.STRING, {"default": "", "tooltip": "首帧图片URL (与image二选一)"}), + "prompt": (IO.STRING, {"multiline": True, "default": "", "tooltip": "文本提示词,最长2000字符"}), + "seed": (IO.INT, {"default": 0, "min": 0, "max": 2147483647, "tooltip": "随机种子"}), + "bgm": (IO.BOOLEAN, {"default": False, "tooltip": "是否添加背景音乐"}), + } + } + + RETURN_TYPES = (IO.VIDEO,) + RETURN_NAMES = ("video",) + FUNCTION = "generate" + CATEGORY = "UCLOUD_MODELVERSE/Vidu" + + def generate(self, client, model, duration, resolution, movement_amplitude, + first_frame_image=None, first_frame_url="", prompt="", seed=0, bgm=False): + api_key = client.get("api_key") + if not api_key: + raise ValueError("API key is not set") + + mv_client = ModelverseClient(api_key) + + # Validate first frame input + 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("请提供 first_frame_image 或 first_frame_url,不能同时提供") + if not has_url and not has_image: + raise ValueError("必须提供 first_frame_image 或 first_frame_url") + + task_input = {} + if has_url: + task_input["first_frame_url"] = first_frame_url.strip() + else: + task_input["first_frame_url"] = image_to_base64(first_frame_image) + + if prompt and prompt.strip(): + task_input["prompt"] = prompt.strip() + + parameters = { + "vidu_type": "img2video", + "duration": duration, + "resolution": resolution, + "movement_amplitude": movement_amplitude, + "seed": seed, + "bgm": bgm, + } + + # Submit task + submit_res = mv_client.submit_task(model, 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}") + + print(f"Vidu I2V task submitted: {task_id}") + + # Poll for result + video_url = self._poll_task(mv_client, task_id) + + # Download and return video + return self._download_video(video_url) + + def _poll_task(self, mv_client, task_id, max_retries=180): + for i in range(max_retries): + 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: + return urls[0] + raise Exception("Task succeeded but no video URL returned") + elif task_status == "Failure": + error = status_res.get("output", {}).get("error_message", "Unknown error") + raise Exception(f"Task failed: {error}") + elif task_status in ["Pending", "Running"]: + print(f"Task {task_id}: {task_status} ({i+1}/{max_retries})") + time.sleep(5) + else: + raise Exception(f"Unknown status: {task_status}") + + raise Exception("Task timed out") + + def _download_video(self, video_url): + response = requests.get(video_url, timeout=120) + response.raise_for_status() + video_io = io.BytesIO(response.content) + video_io.seek(0) + return (VideoFromFile(video_io),) + + +NODE_CLASS_MAPPINGS = { + "Vidu_Img2Video": ViduImg2VideoNode, +} + +NODE_DISPLAY_NAME_MAPPINGS = { + "Vidu_Img2Video": "Vidu Img2Video", +} diff --git a/py/vidu_ref2v.py b/py/vidu_ref2v.py new file mode 100644 index 0000000..e39ddea --- /dev/null +++ b/py/vidu_ref2v.py @@ -0,0 +1,157 @@ +""" +Vidu Reference2Video - 参考生视频模型 +Model: viduq2 +支持1-7张参考图片,生成具备主体一致的视频 +""" +import time +import requests +import io +from .modelverse_api.client import ModelverseClient +from .modelverse_api.utils import image_to_base64 +from comfy.comfy_types.node_typing import IO + +try: + from comfy_extras.nodes_video import VideoFromFile +except ImportError: + try: + from comfy.model_management import VideoFromFile + except ImportError: + 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 + + +ASPECT_RATIOS = ["16:9", "9:16", "3:4", "4:3", "1:1"] +RESOLUTIONS = ["540p", "720p", "1080p"] + + +class ViduReference2VideoNode: + """ + Vidu Reference2Video - 参考生视频 + Model: viduq2 + 支持1-7张参考图片,生成具备主体一致的视频 + """ + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "client": ("MODELVERSE_API_CLIENT",), + "prompt": (IO.STRING, {"multiline": True, "default": "make it dance", "tooltip": "文本提示词,最长2000字符"}), + "duration": (IO.INT, {"default": 5, "min": 1, "max": 10, "step": 1, "tooltip": "视频时长(秒)"}), + "aspect_ratio": (ASPECT_RATIOS, {"default": "16:9", "tooltip": "长宽比"}), + "resolution": (RESOLUTIONS, {"default": "720p", "tooltip": "分辨率"}), + }, + "optional": { + "image1": (IO.IMAGE, {"tooltip": "参考图片1"}), + "image2": (IO.IMAGE, {"tooltip": "参考图片2"}), + "image3": (IO.IMAGE, {"tooltip": "参考图片3"}), + "image4": (IO.IMAGE, {"tooltip": "参考图片4"}), + "image5": (IO.IMAGE, {"tooltip": "参考图片5"}), + "image6": (IO.IMAGE, {"tooltip": "参考图片6"}), + "image7": (IO.IMAGE, {"tooltip": "参考图片7"}), + "image_urls": (IO.STRING, {"default": "", "multiline": True, "tooltip": "参考图片URL列表,每行一个"}), + "seed": (IO.INT, {"default": 0, "min": 0, "max": 2147483647, "tooltip": "随机种子"}), + "bgm": (IO.BOOLEAN, {"default": False, "tooltip": "是否添加背景音乐"}), + } + } + + RETURN_TYPES = (IO.VIDEO,) + RETURN_NAMES = ("video",) + FUNCTION = "generate" + CATEGORY = "UCLOUD_MODELVERSE/Vidu" + + def generate(self, client, prompt, duration, aspect_ratio, resolution, + image1=None, image2=None, image3=None, image4=None, + image5=None, image6=None, image7=None, + image_urls="", seed=0, bgm=False): + api_key = client.get("api_key") + if not api_key: + raise ValueError("API key is not set") + + mv_client = ModelverseClient(api_key) + + # Collect images + images = [] + for img in [image1, image2, image3, image4, image5, image6, image7]: + if img is not None: + images.append(image_to_base64(img)) + + # Add URL images + if image_urls and image_urls.strip(): + for url in image_urls.strip().split('\n'): + url = url.strip() + if url: + images.append(url) + + if not images: + raise ValueError("至少需要提供1张参考图片") + if len(images) > 7: + print(f"Warning: 最多支持7张参考图片,当前{len(images)}张,将只使用前7张") + images = images[:7] + + task_input = { + "images": images, + "prompt": prompt, + } + + parameters = { + "vidu_type": "reference2video", + "duration": duration, + "aspect_ratio": aspect_ratio, + "resolution": resolution, + "seed": seed, + "bgm": bgm, + } + + # Submit task + submit_res = mv_client.submit_task("viduq2", 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}") + + print(f"Vidu Ref2V task submitted: {task_id}") + + # Poll for result + video_url = self._poll_task(mv_client, task_id) + + # Download and return video + return self._download_video(video_url) + + def _poll_task(self, mv_client, task_id, max_retries=180): + for i in range(max_retries): + 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: + return urls[0] + raise Exception("Task succeeded but no video URL returned") + elif task_status == "Failure": + error = status_res.get("output", {}).get("error_message", "Unknown error") + raise Exception(f"Task failed: {error}") + elif task_status in ["Pending", "Running"]: + print(f"Task {task_id}: {task_status} ({i+1}/{max_retries})") + time.sleep(5) + else: + raise Exception(f"Unknown status: {task_status}") + + raise Exception("Task timed out") + + def _download_video(self, video_url): + response = requests.get(video_url, timeout=120) + response.raise_for_status() + video_io = io.BytesIO(response.content) + video_io.seek(0) + return (VideoFromFile(video_io),) + + +NODE_CLASS_MAPPINGS = { + "Vidu_Reference2Video": ViduReference2VideoNode, +} + +NODE_DISPLAY_NAME_MAPPINGS = { + "Vidu_Reference2Video": "Vidu Reference2Video", +} diff --git a/py/vidu_startend2v.py b/py/vidu_startend2v.py new file mode 100644 index 0000000..3a1ff38 --- /dev/null +++ b/py/vidu_startend2v.py @@ -0,0 +1,163 @@ +""" +Vidu StartEnd2Video - 首尾帧生视频模型 +Models: viduq2-pro, viduq2-turbo +""" +import time +import requests +import io +from .modelverse_api.client import ModelverseClient +from .modelverse_api.utils import image_to_base64 +from comfy.comfy_types.node_typing import IO + +try: + from comfy_extras.nodes_video import VideoFromFile +except ImportError: + try: + from comfy.model_management import VideoFromFile + except ImportError: + 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 + + +MODELS = ["viduq2-pro", "viduq2-turbo"] +RESOLUTIONS = ["540p", "720p", "1080p"] +MOVEMENT_AMPLITUDES = ["auto", "small", "medium", "large"] + + +class ViduStartEnd2VideoNode: + """ + Vidu StartEnd2Video - 首尾帧生视频 + Models: viduq2-pro (效果好,细节丰富), viduq2-turbo (效果好,生成快) + 注意:首尾帧分辨率需相近 (0.8~1.25) + """ + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "client": ("MODELVERSE_API_CLIENT",), + "model": (MODELS, {"default": "viduq2-pro", "tooltip": "viduq2-pro: 效果好细节丰富, viduq2-turbo: 生成快"}), + "duration": (IO.INT, {"default": 5, "min": 1, "max": 8, "step": 1, "tooltip": "视频时长(秒), 1-8"}), + "resolution": (RESOLUTIONS, {"default": "720p", "tooltip": "分辨率"}), + "movement_amplitude": (MOVEMENT_AMPLITUDES, {"default": "auto", "tooltip": "运动幅度"}), + }, + "optional": { + "first_frame_image": (IO.IMAGE, {"tooltip": "首帧图片"}), + "first_frame_url": (IO.STRING, {"default": "", "tooltip": "首帧图片URL"}), + "last_frame_image": (IO.IMAGE, {"tooltip": "尾帧图片"}), + "last_frame_url": (IO.STRING, {"default": "", "tooltip": "尾帧图片URL"}), + "prompt": (IO.STRING, {"multiline": True, "default": "", "tooltip": "文本提示词,最长2000字符"}), + "seed": (IO.INT, {"default": 0, "min": 0, "max": 2147483647, "tooltip": "随机种子"}), + "bgm": (IO.BOOLEAN, {"default": False, "tooltip": "是否添加背景音乐"}), + } + } + + RETURN_TYPES = (IO.VIDEO,) + RETURN_NAMES = ("video",) + FUNCTION = "generate" + CATEGORY = "UCLOUD_MODELVERSE/Vidu" + + def generate(self, client, model, duration, resolution, movement_amplitude, + first_frame_image=None, first_frame_url="", + last_frame_image=None, last_frame_url="", + prompt="", seed=0, bgm=False): + api_key = client.get("api_key") + if not api_key: + raise ValueError("API key is not set") + + mv_client = ModelverseClient(api_key) + + # Validate first frame + has_first_url = first_frame_url and first_frame_url.strip() + has_first_image = first_frame_image is not None + if has_first_url and has_first_image: + raise ValueError("首帧:请提供 image 或 url,不能同时提供") + if not has_first_url and not has_first_image: + raise ValueError("必须提供首帧图片") + + # Validate last frame + has_last_url = last_frame_url and last_frame_url.strip() + has_last_image = last_frame_image is not None + if has_last_url and has_last_image: + raise ValueError("尾帧:请提供 image 或 url,不能同时提供") + if not has_last_url and not has_last_image: + raise ValueError("必须提供尾帧图片") + + task_input = {} + + # First frame + if has_first_url: + task_input["first_frame_url"] = first_frame_url.strip() + else: + task_input["first_frame_url"] = image_to_base64(first_frame_image) + + # Last frame + if has_last_url: + task_input["last_frame_url"] = last_frame_url.strip() + else: + task_input["last_frame_url"] = image_to_base64(last_frame_image) + + if prompt and prompt.strip(): + task_input["prompt"] = prompt.strip() + + parameters = { + "vidu_type": "start-end2video", + "duration": duration, + "resolution": resolution, + "movement_amplitude": movement_amplitude, + "seed": seed, + "bgm": bgm, + } + + # Submit task + submit_res = mv_client.submit_task(model, 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}") + + print(f"Vidu StartEnd2V task submitted: {task_id}") + + # Poll for result + video_url = self._poll_task(mv_client, task_id) + + # Download and return video + return self._download_video(video_url) + + def _poll_task(self, mv_client, task_id, max_retries=180): + for i in range(max_retries): + 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: + return urls[0] + raise Exception("Task succeeded but no video URL returned") + elif task_status == "Failure": + error = status_res.get("output", {}).get("error_message", "Unknown error") + raise Exception(f"Task failed: {error}") + elif task_status in ["Pending", "Running"]: + print(f"Task {task_id}: {task_status} ({i+1}/{max_retries})") + time.sleep(5) + else: + raise Exception(f"Unknown status: {task_status}") + + raise Exception("Task timed out") + + def _download_video(self, video_url): + response = requests.get(video_url, timeout=120) + response.raise_for_status() + video_io = io.BytesIO(response.content) + video_io.seek(0) + return (VideoFromFile(video_io),) + + +NODE_CLASS_MAPPINGS = { + "Vidu_StartEnd2Video": ViduStartEnd2VideoNode, +} + +NODE_DISPLAY_NAME_MAPPINGS = { + "Vidu_StartEnd2Video": "Vidu StartEnd2Video", +} diff --git a/py/vidu_t2v.py b/py/vidu_t2v.py new file mode 100644 index 0000000..3bc1e03 --- /dev/null +++ b/py/vidu_t2v.py @@ -0,0 +1,122 @@ +""" +Vidu Text2Video - 文生视频模型 +Model: viduq2 +""" +import time +import requests +import io +from .modelverse_api.client import ModelverseClient +from comfy.comfy_types.node_typing import IO + +try: + from comfy_extras.nodes_video import VideoFromFile +except ImportError: + try: + from comfy.model_management import VideoFromFile + except ImportError: + 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 + + +ASPECT_RATIOS = ["16:9", "9:16", "3:4", "4:3", "1:1"] +RESOLUTIONS = ["540p", "720p", "1080p"] + + +class ViduText2VideoNode: + """ + Vidu Text2Video - 文生视频 + Model: viduq2 + """ + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "client": ("MODELVERSE_API_CLIENT",), + "prompt": (IO.STRING, {"multiline": True, "default": "A beautiful sunset over the ocean", "tooltip": "文本提示词,最长2000字符"}), + "duration": (IO.INT, {"default": 5, "min": 1, "max": 10, "step": 1, "tooltip": "视频时长(秒)"}), + "aspect_ratio": (ASPECT_RATIOS, {"default": "16:9", "tooltip": "长宽比"}), + "resolution": (RESOLUTIONS, {"default": "720p", "tooltip": "分辨率"}), + }, + "optional": { + "seed": (IO.INT, {"default": 0, "min": 0, "max": 2147483647, "tooltip": "随机种子,0表示随机"}), + "guidance_scale": (IO.FLOAT, {"default": 7.5, "min": 1.0, "max": 20.0, "step": 0.5, "tooltip": "引导系数"}), + "bgm": (IO.BOOLEAN, {"default": False, "tooltip": "是否添加背景音乐"}), + } + } + + RETURN_TYPES = (IO.VIDEO,) + RETURN_NAMES = ("video",) + FUNCTION = "generate" + CATEGORY = "UCLOUD_MODELVERSE/Vidu" + + def generate(self, client, prompt, duration, aspect_ratio, resolution, seed=0, guidance_scale=7.5, bgm=False): + api_key = client.get("api_key") + if not api_key: + raise ValueError("API key is not set") + + mv_client = ModelverseClient(api_key) + + task_input = {"prompt": prompt} + parameters = { + "vidu_type": "text2video", + "duration": duration, + "aspect_ratio": aspect_ratio, + "resolution": resolution, + "seed": seed, + "guidance_scale": guidance_scale, + "bgm": bgm, + } + + # Submit task + submit_res = mv_client.submit_task("viduq2", 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}") + + print(f"Vidu T2V task submitted: {task_id}") + + # Poll for result + video_url = self._poll_task(mv_client, task_id) + + # Download and return video + return self._download_video(video_url) + + def _poll_task(self, mv_client, task_id, max_retries=180): + for i in range(max_retries): + 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: + return urls[0] + raise Exception("Task succeeded but no video URL returned") + elif task_status == "Failure": + error = status_res.get("output", {}).get("error_message", "Unknown error") + raise Exception(f"Task failed: {error}") + elif task_status in ["Pending", "Running"]: + print(f"Task {task_id}: {task_status} ({i+1}/{max_retries})") + time.sleep(5) + else: + raise Exception(f"Unknown status: {task_status}") + + raise Exception("Task timed out") + + def _download_video(self, video_url): + response = requests.get(video_url, timeout=120) + response.raise_for_status() + video_io = io.BytesIO(response.content) + video_io.seek(0) + return (VideoFromFile(video_io),) + + +NODE_CLASS_MAPPINGS = { + "Vidu_Text2Video": ViduText2VideoNode, +} + +NODE_DISPLAY_NAME_MAPPINGS = { + "Vidu_Text2Video": "Vidu Text2Video", +} From 7a3091df10e79ede3f46717474099bc3b6dc2bf3 Mon Sep 17 00:00:00 2001 From: "jason.mei" Date: Mon, 29 Dec 2025 17:53:31 +0800 Subject: [PATCH 02/12] feat(wan_ai_t2v): improve video generation and error handling - Remove unused imports (os, tempfile) - Add fallback VideoFromFile class for better compatibility - Simplify video download and processing logic - Add timeout parameter (120s) to video download request - Improve error handling with specific ImportError catching - Reduce code complexity by removing redundant checks and logging - Streamline BytesIO object creation and return statement - Enhance robustness when VideoFromFile is not available in ComfyUI --- py/wan_ai_t2v.py | 42 ++++++++++-------------------------------- 1 file changed, 10 insertions(+), 32 deletions(-) diff --git a/py/wan_ai_t2v.py b/py/wan_ai_t2v.py index f90bee2..d063e94 100644 --- a/py/wan_ai_t2v.py +++ b/py/wan_ai_t2v.py @@ -1,20 +1,21 @@ import time import requests -import os -import tempfile import io from .modelverse_api.client import ModelverseClient from comfy.comfy_types.node_typing import IO + # 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") + except ImportError: + # Fallback class when VideoFromFile is not available + 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 class Modelverse_WanAIT2V: def __init__(self): @@ -83,34 +84,11 @@ class Modelverse_WanAIT2V: raise Exception(f"Unknown task status: {task_status}") # 3. Download video - response = requests.get(video_url) + response = requests.get(video_url, timeout=120) 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 = io.BytesIO(response.content) 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,) + return (VideoFromFile(video_io),) NODE_CLASS_MAPPINGS = { From 20093d1c40995d48bb00a11f61051cf5a4f83a07 Mon Sep 17 00:00:00 2001 From: "jason.mei" Date: Mon, 29 Dec 2025 17:54:47 +0800 Subject: [PATCH 03/12] feat(wan_ai_i2v): simplify video handling and improve error management - Remove unused imports (os, tempfile) to reduce dependencies - Simplify VideoFromFile import fallback logic by removing unnecessary comments - Remove get_dimensions() method from VideoFromFile wrapper class - Consolidate video download logic and remove redundant error handling - Increase download timeout from 60s to 120s for better reliability - Remove verbose print statements for cleaner logging - Simplify video data processing by directly using response.content - Streamline return statement to directly instantiate VideoFromFile - Remove fallback video_data return path for consistent API behavior - Improve code readability and maintainability through simplification --- py/wan_ai_i2v.py | 46 +++++----------------------------------------- 1 file changed, 5 insertions(+), 41 deletions(-) diff --git a/py/wan_ai_i2v.py b/py/wan_ai_i2v.py index d0cd2d9..3d685db 100644 --- a/py/wan_ai_i2v.py +++ b/py/wan_ai_i2v.py @@ -1,29 +1,20 @@ import time import requests -import os -import tempfile import io from .modelverse_api.client import ModelverseClient from .modelverse_api.utils import image_to_base64 from comfy.comfy_types.node_typing import IO -# 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): @@ -157,38 +148,11 @@ class Modelverse_WanAII2V: 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 + response = requests.get(video_url, timeout=120) + response.raise_for_status() + video_io = io.BytesIO(response.content) 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,) + return (VideoFromFile(video_io),) NODE_CLASS_MAPPINGS = { From c2d2ec96b2bc3672d43a9ed5268282f191986730 Mon Sep 17 00:00:00 2001 From: "jason.mei" Date: Mon, 29 Dec 2025 18:11:04 +0800 Subject: [PATCH 04/12] feat(vidu,wan_ai): add video dimension extraction with av library - Add `get_dimensions()` method to VideoFromFile fallback class across all vidu and wan_ai modules - Implement PyAV (av) library integration to extract video width and height from container streams - Add dimension caching with `_width` and `_height` attributes to avoid redundant parsing - Implement fallback dimensions (1280x720) when video parsing fails - Add proper stream seeking to reset file position after dimension extraction - Update requirements.txt to include av library dependency - Ensure consistent video dimension handling across vidu_t2v, vidu_i2v, vidu_ref2v, vidu_startend2v, vidu_extend, wan_ai_t2v, and wan_ai_i2v modules --- py/vidu_extend.py | 18 ++++++++++++++++++ py/vidu_i2v.py | 18 ++++++++++++++++++ py/vidu_ref2v.py | 18 ++++++++++++++++++ py/vidu_startend2v.py | 18 ++++++++++++++++++ py/vidu_t2v.py | 18 ++++++++++++++++++ py/wan_ai_i2v.py | 18 ++++++++++++++++++ py/wan_ai_t2v.py | 19 ++++++++++++++++++- requirements.txt | 3 ++- 8 files changed, 128 insertions(+), 2 deletions(-) diff --git a/py/vidu_extend.py b/py/vidu_extend.py index 9e094c0..1a8e67f 100644 --- a/py/vidu_extend.py +++ b/py/vidu_extend.py @@ -15,10 +15,28 @@ except ImportError: try: from comfy.model_management import VideoFromFile except ImportError: + import av + 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 + self._width = None + self._height = None + + def get_dimensions(self): + if self._width is None: + self.video_io.seek(0) + try: + container = av.open(self.video_io) + stream = container.streams.video[0] + self._width = stream.width + self._height = stream.height + container.close() + except: + self._width, self._height = 1280, 720 + self.video_io.seek(0) + return (self._width, self._height) MODELS = ["viduq2-pro", "viduq2-turbo"] diff --git a/py/vidu_i2v.py b/py/vidu_i2v.py index 5afe8dc..ad56ec3 100644 --- a/py/vidu_i2v.py +++ b/py/vidu_i2v.py @@ -15,10 +15,28 @@ except ImportError: try: from comfy.model_management import VideoFromFile except ImportError: + import av + 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 + self._width = None + self._height = None + + def get_dimensions(self): + if self._width is None: + self.video_io.seek(0) + try: + container = av.open(self.video_io) + stream = container.streams.video[0] + self._width = stream.width + self._height = stream.height + container.close() + except: + self._width, self._height = 1280, 720 + self.video_io.seek(0) + return (self._width, self._height) MODELS = ["viduq2-pro", "viduq2-turbo"] diff --git a/py/vidu_ref2v.py b/py/vidu_ref2v.py index e39ddea..fffd9af 100644 --- a/py/vidu_ref2v.py +++ b/py/vidu_ref2v.py @@ -16,10 +16,28 @@ except ImportError: try: from comfy.model_management import VideoFromFile except ImportError: + import av + 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 + self._width = None + self._height = None + + def get_dimensions(self): + if self._width is None: + self.video_io.seek(0) + try: + container = av.open(self.video_io) + stream = container.streams.video[0] + self._width = stream.width + self._height = stream.height + container.close() + except: + self._width, self._height = 1280, 720 + self.video_io.seek(0) + return (self._width, self._height) ASPECT_RATIOS = ["16:9", "9:16", "3:4", "4:3", "1:1"] diff --git a/py/vidu_startend2v.py b/py/vidu_startend2v.py index 3a1ff38..38c7a50 100644 --- a/py/vidu_startend2v.py +++ b/py/vidu_startend2v.py @@ -15,10 +15,28 @@ except ImportError: try: from comfy.model_management import VideoFromFile except ImportError: + import av + 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 + self._width = None + self._height = None + + def get_dimensions(self): + if self._width is None: + self.video_io.seek(0) + try: + container = av.open(self.video_io) + stream = container.streams.video[0] + self._width = stream.width + self._height = stream.height + container.close() + except: + self._width, self._height = 1280, 720 + self.video_io.seek(0) + return (self._width, self._height) MODELS = ["viduq2-pro", "viduq2-turbo"] diff --git a/py/vidu_t2v.py b/py/vidu_t2v.py index 3bc1e03..db302cc 100644 --- a/py/vidu_t2v.py +++ b/py/vidu_t2v.py @@ -14,10 +14,28 @@ except ImportError: try: from comfy.model_management import VideoFromFile except ImportError: + import av + 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 + self._width = None + self._height = None + + def get_dimensions(self): + if self._width is None: + self.video_io.seek(0) + try: + container = av.open(self.video_io) + stream = container.streams.video[0] + self._width = stream.width + self._height = stream.height + container.close() + except: + self._width, self._height = 1280, 720 + self.video_io.seek(0) + return (self._width, self._height) ASPECT_RATIOS = ["16:9", "9:16", "3:4", "4:3", "1:1"] diff --git a/py/wan_ai_i2v.py b/py/wan_ai_i2v.py index 3d685db..489801a 100644 --- a/py/wan_ai_i2v.py +++ b/py/wan_ai_i2v.py @@ -11,10 +11,28 @@ except ImportError: try: from comfy.model_management import VideoFromFile except ImportError: + import av + 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 + self._width = None + self._height = None + + def get_dimensions(self): + if self._width is None: + self.video_io.seek(0) + try: + container = av.open(self.video_io) + stream = container.streams.video[0] + self._width = stream.width + self._height = stream.height + container.close() + except: + self._width, self._height = 1280, 720 + self.video_io.seek(0) + return (self._width, self._height) class Modelverse_WanAII2V: def __init__(self): diff --git a/py/wan_ai_t2v.py b/py/wan_ai_t2v.py index d063e94..27c94db 100644 --- a/py/wan_ai_t2v.py +++ b/py/wan_ai_t2v.py @@ -11,11 +11,28 @@ except ImportError: try: from comfy.model_management import VideoFromFile except ImportError: - # Fallback class when VideoFromFile is not available + import av + 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 + self._width = None + self._height = None + + def get_dimensions(self): + if self._width is None: + self.video_io.seek(0) + try: + container = av.open(self.video_io) + stream = container.streams.video[0] + self._width = stream.width + self._height = stream.height + container.close() + except: + self._width, self._height = 1280, 720 + self.video_io.seek(0) + return (self._width, self._height) class Modelverse_WanAIT2V: def __init__(self): diff --git a/requirements.txt b/requirements.txt index 2cbf9ad..5add0cd 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ requests >= 2.32.4 pillow >= 11 -openai >= 1.107.0 \ No newline at end of file +openai >= 1.107.0 +GitPython==3.1.45 \ No newline at end of file From ee2debe867f40d9d5f2b693543926ec36f211b08 Mon Sep 17 00:00:00 2001 From: "jason.mei" Date: Mon, 29 Dec 2025 18:32:21 +0800 Subject: [PATCH 05/12] refactor(vidu,wan_ai): remove duplicate VideoFromFile fallback implementation - Remove redundant VideoFromFile class implementations from vidu_extend.py, vidu_i2v.py, vidu_ref2v.py, vidu_startend2v.py, and vidu_t2v.py - Remove duplicate VideoFromFile fallback from wan_ai_i2v.py and wan_ai_t2v.py - Simplify import logic by relying on comfy library's VideoFromFile implementation - Reduce code duplication across multiple video generation modules - Improve maintainability by centralizing video handling logic --- py/vidu_extend.py | 25 ------------------------- py/vidu_i2v.py | 24 ------------------------ py/vidu_ref2v.py | 25 ------------------------- py/vidu_startend2v.py | 25 ------------------------- py/vidu_t2v.py | 25 ------------------------- py/wan_ai_i2v.py | 24 ------------------------ py/wan_ai_t2v.py | 25 ------------------------- 7 files changed, 173 deletions(-) diff --git a/py/vidu_extend.py b/py/vidu_extend.py index 1a8e67f..dca950c 100644 --- a/py/vidu_extend.py +++ b/py/vidu_extend.py @@ -8,36 +8,11 @@ import io from .modelverse_api.client import ModelverseClient from .modelverse_api.utils import image_to_base64 from comfy.comfy_types.node_typing import IO - try: from comfy_extras.nodes_video import VideoFromFile except ImportError: try: from comfy.model_management import VideoFromFile - except ImportError: - import av - - 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 - self._width = None - self._height = None - - def get_dimensions(self): - if self._width is None: - self.video_io.seek(0) - try: - container = av.open(self.video_io) - stream = container.streams.video[0] - self._width = stream.width - self._height = stream.height - container.close() - except: - self._width, self._height = 1280, 720 - self.video_io.seek(0) - return (self._width, self._height) - MODELS = ["viduq2-pro", "viduq2-turbo"] RESOLUTIONS = ["540p", "720p", "1080p"] diff --git a/py/vidu_i2v.py b/py/vidu_i2v.py index ad56ec3..aa84380 100644 --- a/py/vidu_i2v.py +++ b/py/vidu_i2v.py @@ -8,35 +8,11 @@ import io from .modelverse_api.client import ModelverseClient from .modelverse_api.utils import image_to_base64 from comfy.comfy_types.node_typing import IO - try: from comfy_extras.nodes_video import VideoFromFile except ImportError: try: from comfy.model_management import VideoFromFile - except ImportError: - import av - - 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 - self._width = None - self._height = None - - def get_dimensions(self): - if self._width is None: - self.video_io.seek(0) - try: - container = av.open(self.video_io) - stream = container.streams.video[0] - self._width = stream.width - self._height = stream.height - container.close() - except: - self._width, self._height = 1280, 720 - self.video_io.seek(0) - return (self._width, self._height) MODELS = ["viduq2-pro", "viduq2-turbo"] diff --git a/py/vidu_ref2v.py b/py/vidu_ref2v.py index fffd9af..5dd56ec 100644 --- a/py/vidu_ref2v.py +++ b/py/vidu_ref2v.py @@ -9,36 +9,11 @@ import io from .modelverse_api.client import ModelverseClient from .modelverse_api.utils import image_to_base64 from comfy.comfy_types.node_typing import IO - try: from comfy_extras.nodes_video import VideoFromFile except ImportError: try: from comfy.model_management import VideoFromFile - except ImportError: - import av - - 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 - self._width = None - self._height = None - - def get_dimensions(self): - if self._width is None: - self.video_io.seek(0) - try: - container = av.open(self.video_io) - stream = container.streams.video[0] - self._width = stream.width - self._height = stream.height - container.close() - except: - self._width, self._height = 1280, 720 - self.video_io.seek(0) - return (self._width, self._height) - ASPECT_RATIOS = ["16:9", "9:16", "3:4", "4:3", "1:1"] RESOLUTIONS = ["540p", "720p", "1080p"] diff --git a/py/vidu_startend2v.py b/py/vidu_startend2v.py index 38c7a50..7447b1a 100644 --- a/py/vidu_startend2v.py +++ b/py/vidu_startend2v.py @@ -8,36 +8,11 @@ import io from .modelverse_api.client import ModelverseClient from .modelverse_api.utils import image_to_base64 from comfy.comfy_types.node_typing import IO - try: from comfy_extras.nodes_video import VideoFromFile except ImportError: try: from comfy.model_management import VideoFromFile - except ImportError: - import av - - 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 - self._width = None - self._height = None - - def get_dimensions(self): - if self._width is None: - self.video_io.seek(0) - try: - container = av.open(self.video_io) - stream = container.streams.video[0] - self._width = stream.width - self._height = stream.height - container.close() - except: - self._width, self._height = 1280, 720 - self.video_io.seek(0) - return (self._width, self._height) - MODELS = ["viduq2-pro", "viduq2-turbo"] RESOLUTIONS = ["540p", "720p", "1080p"] diff --git a/py/vidu_t2v.py b/py/vidu_t2v.py index db302cc..fae67c3 100644 --- a/py/vidu_t2v.py +++ b/py/vidu_t2v.py @@ -7,36 +7,11 @@ import requests import io from .modelverse_api.client import ModelverseClient from comfy.comfy_types.node_typing import IO - try: from comfy_extras.nodes_video import VideoFromFile except ImportError: try: from comfy.model_management import VideoFromFile - except ImportError: - import av - - 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 - self._width = None - self._height = None - - def get_dimensions(self): - if self._width is None: - self.video_io.seek(0) - try: - container = av.open(self.video_io) - stream = container.streams.video[0] - self._width = stream.width - self._height = stream.height - container.close() - except: - self._width, self._height = 1280, 720 - self.video_io.seek(0) - return (self._width, self._height) - ASPECT_RATIOS = ["16:9", "9:16", "3:4", "4:3", "1:1"] RESOLUTIONS = ["540p", "720p", "1080p"] diff --git a/py/wan_ai_i2v.py b/py/wan_ai_i2v.py index 489801a..1526296 100644 --- a/py/wan_ai_i2v.py +++ b/py/wan_ai_i2v.py @@ -4,36 +4,12 @@ import io from .modelverse_api.client import ModelverseClient from .modelverse_api.utils import image_to_base64 from comfy.comfy_types.node_typing import IO - try: from comfy_extras.nodes_video import VideoFromFile except ImportError: try: from comfy.model_management import VideoFromFile - except ImportError: - import av - 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 - self._width = None - self._height = None - - def get_dimensions(self): - if self._width is None: - self.video_io.seek(0) - try: - container = av.open(self.video_io) - stream = container.streams.video[0] - self._width = stream.width - self._height = stream.height - container.close() - except: - self._width, self._height = 1280, 720 - self.video_io.seek(0) - return (self._width, self._height) - class Modelverse_WanAII2V: def __init__(self): pass diff --git a/py/wan_ai_t2v.py b/py/wan_ai_t2v.py index 27c94db..1dc35b1 100644 --- a/py/wan_ai_t2v.py +++ b/py/wan_ai_t2v.py @@ -3,36 +3,11 @@ import requests import io from .modelverse_api.client import ModelverseClient from comfy.comfy_types.node_typing import IO - -# Import VideoFromFile class for ComfyUI video handling try: from comfy_extras.nodes_video import VideoFromFile except ImportError: try: from comfy.model_management import VideoFromFile - except ImportError: - import av - - 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 - self._width = None - self._height = None - - def get_dimensions(self): - if self._width is None: - self.video_io.seek(0) - try: - container = av.open(self.video_io) - stream = container.streams.video[0] - self._width = stream.width - self._height = stream.height - container.close() - except: - self._width, self._height = 1280, 720 - self.video_io.seek(0) - return (self._width, self._height) class Modelverse_WanAIT2V: def __init__(self): From 7da714c686e066868efc2aa7e43e863d861c0dfd Mon Sep 17 00:00:00 2001 From: "jason.mei" Date: Mon, 29 Dec 2025 18:36:16 +0800 Subject: [PATCH 06/12] refactor(vidu,wan_ai): add fallback VideoFromFile wrapper for import compatibility - Add third-level fallback import handler for VideoFromFile class across all video modules - Implement simple VideoFromFile wrapper class with default dimensions (1280x720) when imports fail - Support video_io objects with getvalue() method for BytesIO compatibility - Add clarifying comments for each import path (newer, older, and fallback) - Apply consistent fallback pattern to vidu_extend, vidu_i2v, vidu_ref2v, vidu_startend2v, vidu_t2v, wan_ai_i2v, and wan_ai_t2v modules - Improve robustness for different ComfyUI versions and installation configurations --- py/vidu_extend.py | 12 ++++++++++++ py/vidu_i2v.py | 12 ++++++++++++ py/vidu_ref2v.py | 12 ++++++++++++ py/vidu_startend2v.py | 12 ++++++++++++ py/vidu_t2v.py | 12 ++++++++++++ py/wan_ai_i2v.py | 12 ++++++++++++ py/wan_ai_t2v.py | 12 ++++++++++++ 7 files changed, 84 insertions(+) diff --git a/py/vidu_extend.py b/py/vidu_extend.py index dca950c..29d37bc 100644 --- a/py/vidu_extend.py +++ b/py/vidu_extend.py @@ -9,10 +9,22 @@ from .modelverse_api.client import ModelverseClient from .modelverse_api.utils import image_to_base64 from comfy.comfy_types.node_typing import IO 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 MODELS = ["viduq2-pro", "viduq2-turbo"] RESOLUTIONS = ["540p", "720p", "1080p"] diff --git a/py/vidu_i2v.py b/py/vidu_i2v.py index aa84380..9663bd8 100644 --- a/py/vidu_i2v.py +++ b/py/vidu_i2v.py @@ -9,10 +9,22 @@ from .modelverse_api.client import ModelverseClient from .modelverse_api.utils import image_to_base64 from comfy.comfy_types.node_typing import IO 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 MODELS = ["viduq2-pro", "viduq2-turbo"] diff --git a/py/vidu_ref2v.py b/py/vidu_ref2v.py index 5dd56ec..25a186f 100644 --- a/py/vidu_ref2v.py +++ b/py/vidu_ref2v.py @@ -10,10 +10,22 @@ from .modelverse_api.client import ModelverseClient from .modelverse_api.utils import image_to_base64 from comfy.comfy_types.node_typing import IO 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 ASPECT_RATIOS = ["16:9", "9:16", "3:4", "4:3", "1:1"] RESOLUTIONS = ["540p", "720p", "1080p"] diff --git a/py/vidu_startend2v.py b/py/vidu_startend2v.py index 7447b1a..142c5a4 100644 --- a/py/vidu_startend2v.py +++ b/py/vidu_startend2v.py @@ -9,10 +9,22 @@ from .modelverse_api.client import ModelverseClient from .modelverse_api.utils import image_to_base64 from comfy.comfy_types.node_typing import IO 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 MODELS = ["viduq2-pro", "viduq2-turbo"] RESOLUTIONS = ["540p", "720p", "1080p"] diff --git a/py/vidu_t2v.py b/py/vidu_t2v.py index fae67c3..72211b5 100644 --- a/py/vidu_t2v.py +++ b/py/vidu_t2v.py @@ -8,10 +8,22 @@ import io from .modelverse_api.client import ModelverseClient from comfy.comfy_types.node_typing import IO 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 ASPECT_RATIOS = ["16:9", "9:16", "3:4", "4:3", "1:1"] RESOLUTIONS = ["540p", "720p", "1080p"] diff --git a/py/wan_ai_i2v.py b/py/wan_ai_i2v.py index 1526296..f55feae 100644 --- a/py/wan_ai_i2v.py +++ b/py/wan_ai_i2v.py @@ -5,10 +5,22 @@ from .modelverse_api.client import ModelverseClient from .modelverse_api.utils import image_to_base64 from comfy.comfy_types.node_typing import IO 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): diff --git a/py/wan_ai_t2v.py b/py/wan_ai_t2v.py index 1dc35b1..4aaf6d8 100644 --- a/py/wan_ai_t2v.py +++ b/py/wan_ai_t2v.py @@ -4,10 +4,22 @@ import io from .modelverse_api.client import ModelverseClient from comfy.comfy_types.node_typing import IO 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_WanAIT2V: def __init__(self): From 8715c032aea6cb1937c6b746b9af51d8c01cac9e Mon Sep 17 00:00:00 2001 From: "jason.mei" Date: Tue, 30 Dec 2025 10:29:46 +0800 Subject: [PATCH 07/12] feat(vidu,wan_ai): refactor video generation to return URLs and add preview node - Add new ModelversePreviewVideo node for downloading and previewing generated videos - Change all Vidu video generation nodes (extend, i2v, ref2v, startend2v, t2v) to return video URL and task ID instead of downloaded video objects - Change all Wan AI video generation nodes (i2v, t2v) to return video URL and task ID instead of downloaded video objects - Remove duplicate VideoFromFile import fallback logic from all video generation modules - Simplify video handling by delegating download responsibility to preview node - Improve separation of concerns between generation and preview workflows --- py/preview_video.py | 66 +++++++++++++++++++++++++++++++++++++++++++ py/vidu_extend.py | 34 +++------------------- py/vidu_i2v.py | 33 ++-------------------- py/vidu_ref2v.py | 34 +++------------------- py/vidu_startend2v.py | 34 +++------------------- py/vidu_t2v.py | 34 +++------------------- py/wan_ai_i2v.py | 35 ++++------------------- py/wan_ai_t2v.py | 33 ++++------------------ 8 files changed, 96 insertions(+), 207 deletions(-) create mode 100644 py/preview_video.py diff --git a/py/preview_video.py b/py/preview_video.py new file mode 100644 index 0000000..956d3ab --- /dev/null +++ b/py/preview_video.py @@ -0,0 +1,66 @@ +import os +import re +import requests +import folder_paths + + +class ModelversePreviewVideo: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "video_url": ("STRING", {"forceInput": True}), + "filename_prefix": ("STRING", {"default": "Modelverse"}), + "save_output": ("BOOLEAN", {"default": True}), + } + } + + OUTPUT_NODE = True + FUNCTION = "run" + CATEGORY = "UCLOUD_MODELVERSE" + + RETURN_TYPES = ("STRING",) + RETURN_NAMES = ("file_path",) + + def run(self, video_url, filename_prefix, save_output): + if not save_output: + return {"ui": {"video_url": [video_url]}, "result": ('',)} + + output_dir = folder_paths.get_output_directory() + ( + full_output_folder, + filename, + _, + _, + _, + ) = folder_paths.get_save_image_path(filename_prefix, output_dir) + + max_counter = 0 + matcher = re.compile(f"{re.escape(filename)}_(\\d+)\\D*\\..+", re.IGNORECASE) + for existing_file in os.listdir(full_output_folder): + match = matcher.fullmatch(existing_file) + if match: + file_counter = int(match.group(1)) + if file_counter > max_counter: + max_counter = file_counter + + counter = max_counter + 1 + file = f"{filename}_{counter:05}.mp4" + file_path = os.path.join(full_output_folder, file) + + if type(video_url) == list: + video_url = video_url[0] + + response = requests.get(video_url, stream=True) + open(file_path, "wb").write(response.content) + + return {"ui": {"video_url": [video_url]}, "result": (file_path,)} + + +NODE_CLASS_MAPPINGS = { + "Modelverse_PreviewVideo": ModelversePreviewVideo, +} + +NODE_DISPLAY_NAME_MAPPINGS = { + "Modelverse_PreviewVideo": "Modelverse Preview Video", +} diff --git a/py/vidu_extend.py b/py/vidu_extend.py index 29d37bc..a094fdd 100644 --- a/py/vidu_extend.py +++ b/py/vidu_extend.py @@ -3,28 +3,10 @@ Vidu Extend - 视频延长模型 Models: viduq2-pro, viduq2-turbo """ import time -import requests -import io from .modelverse_api.client import ModelverseClient from .modelverse_api.utils import image_to_base64 from comfy.comfy_types.node_typing import IO -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 + MODELS = ["viduq2-pro", "viduq2-turbo"] RESOLUTIONS = ["540p", "720p", "1080p"] @@ -54,8 +36,8 @@ class ViduExtendNode: } } - RETURN_TYPES = (IO.VIDEO,) - RETURN_NAMES = ("video",) + RETURN_TYPES = (IO.STRING, IO.STRING) + RETURN_NAMES = ("url", "task_id") FUNCTION = "generate" CATEGORY = "UCLOUD_MODELVERSE/Vidu" @@ -105,8 +87,7 @@ class ViduExtendNode: # Poll for result video_url_result = self._poll_task(mv_client, task_id) - # Download and return video - return self._download_video(video_url_result) + return (video_url_result, task_id) def _poll_task(self, mv_client, task_id, max_retries=180): for i in range(max_retries): @@ -129,13 +110,6 @@ class ViduExtendNode: raise Exception("Task timed out") - def _download_video(self, video_url): - response = requests.get(video_url, timeout=120) - response.raise_for_status() - video_io = io.BytesIO(response.content) - video_io.seek(0) - return (VideoFromFile(video_io),) - NODE_CLASS_MAPPINGS = { "Vidu_Extend": ViduExtendNode, diff --git a/py/vidu_i2v.py b/py/vidu_i2v.py index 9663bd8..60ef6b2 100644 --- a/py/vidu_i2v.py +++ b/py/vidu_i2v.py @@ -3,28 +3,9 @@ Vidu Img2Video - 图生视频模型 Models: viduq2-pro, viduq2-turbo """ import time -import requests -import io from .modelverse_api.client import ModelverseClient from .modelverse_api.utils import image_to_base64 from comfy.comfy_types.node_typing import IO -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 MODELS = ["viduq2-pro", "viduq2-turbo"] @@ -57,8 +38,8 @@ class ViduImg2VideoNode: } } - RETURN_TYPES = (IO.VIDEO,) - RETURN_NAMES = ("video",) + RETURN_TYPES = (IO.STRING, IO.STRING) + RETURN_NAMES = ("url", "task_id") FUNCTION = "generate" CATEGORY = "UCLOUD_MODELVERSE/Vidu" @@ -108,8 +89,7 @@ class ViduImg2VideoNode: # Poll for result video_url = self._poll_task(mv_client, task_id) - # Download and return video - return self._download_video(video_url) + return (video_url, task_id) def _poll_task(self, mv_client, task_id, max_retries=180): for i in range(max_retries): @@ -132,13 +112,6 @@ class ViduImg2VideoNode: raise Exception("Task timed out") - def _download_video(self, video_url): - response = requests.get(video_url, timeout=120) - response.raise_for_status() - video_io = io.BytesIO(response.content) - video_io.seek(0) - return (VideoFromFile(video_io),) - NODE_CLASS_MAPPINGS = { "Vidu_Img2Video": ViduImg2VideoNode, diff --git a/py/vidu_ref2v.py b/py/vidu_ref2v.py index 25a186f..a0892fc 100644 --- a/py/vidu_ref2v.py +++ b/py/vidu_ref2v.py @@ -4,28 +4,10 @@ Model: viduq2 支持1-7张参考图片,生成具备主体一致的视频 """ import time -import requests -import io from .modelverse_api.client import ModelverseClient from .modelverse_api.utils import image_to_base64 from comfy.comfy_types.node_typing import IO -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 + ASPECT_RATIOS = ["16:9", "9:16", "3:4", "4:3", "1:1"] RESOLUTIONS = ["540p", "720p", "1080p"] @@ -62,8 +44,8 @@ class ViduReference2VideoNode: } } - RETURN_TYPES = (IO.VIDEO,) - RETURN_NAMES = ("video",) + RETURN_TYPES = (IO.STRING, IO.STRING) + RETURN_NAMES = ("url", "task_id") FUNCTION = "generate" CATEGORY = "UCLOUD_MODELVERSE/Vidu" @@ -121,8 +103,7 @@ class ViduReference2VideoNode: # Poll for result video_url = self._poll_task(mv_client, task_id) - # Download and return video - return self._download_video(video_url) + return (video_url, task_id) def _poll_task(self, mv_client, task_id, max_retries=180): for i in range(max_retries): @@ -145,13 +126,6 @@ class ViduReference2VideoNode: raise Exception("Task timed out") - def _download_video(self, video_url): - response = requests.get(video_url, timeout=120) - response.raise_for_status() - video_io = io.BytesIO(response.content) - video_io.seek(0) - return (VideoFromFile(video_io),) - NODE_CLASS_MAPPINGS = { "Vidu_Reference2Video": ViduReference2VideoNode, diff --git a/py/vidu_startend2v.py b/py/vidu_startend2v.py index 142c5a4..0d7c213 100644 --- a/py/vidu_startend2v.py +++ b/py/vidu_startend2v.py @@ -3,28 +3,10 @@ Vidu StartEnd2Video - 首尾帧生视频模型 Models: viduq2-pro, viduq2-turbo """ import time -import requests -import io from .modelverse_api.client import ModelverseClient from .modelverse_api.utils import image_to_base64 from comfy.comfy_types.node_typing import IO -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 + MODELS = ["viduq2-pro", "viduq2-turbo"] RESOLUTIONS = ["540p", "720p", "1080p"] @@ -59,8 +41,8 @@ class ViduStartEnd2VideoNode: } } - RETURN_TYPES = (IO.VIDEO,) - RETURN_NAMES = ("video",) + RETURN_TYPES = (IO.STRING, IO.STRING) + RETURN_NAMES = ("url", "task_id") FUNCTION = "generate" CATEGORY = "UCLOUD_MODELVERSE/Vidu" @@ -127,8 +109,7 @@ class ViduStartEnd2VideoNode: # Poll for result video_url = self._poll_task(mv_client, task_id) - # Download and return video - return self._download_video(video_url) + return (video_url, task_id) def _poll_task(self, mv_client, task_id, max_retries=180): for i in range(max_retries): @@ -151,13 +132,6 @@ class ViduStartEnd2VideoNode: raise Exception("Task timed out") - def _download_video(self, video_url): - response = requests.get(video_url, timeout=120) - response.raise_for_status() - video_io = io.BytesIO(response.content) - video_io.seek(0) - return (VideoFromFile(video_io),) - NODE_CLASS_MAPPINGS = { "Vidu_StartEnd2Video": ViduStartEnd2VideoNode, diff --git a/py/vidu_t2v.py b/py/vidu_t2v.py index 72211b5..32261e8 100644 --- a/py/vidu_t2v.py +++ b/py/vidu_t2v.py @@ -3,27 +3,9 @@ Vidu Text2Video - 文生视频模型 Model: viduq2 """ import time -import requests -import io from .modelverse_api.client import ModelverseClient from comfy.comfy_types.node_typing import IO -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 + ASPECT_RATIOS = ["16:9", "9:16", "3:4", "4:3", "1:1"] RESOLUTIONS = ["540p", "720p", "1080p"] @@ -52,8 +34,8 @@ class ViduText2VideoNode: } } - RETURN_TYPES = (IO.VIDEO,) - RETURN_NAMES = ("video",) + RETURN_TYPES = (IO.STRING, IO.STRING) + RETURN_NAMES = ("url", "task_id") FUNCTION = "generate" CATEGORY = "UCLOUD_MODELVERSE/Vidu" @@ -86,8 +68,7 @@ class ViduText2VideoNode: # Poll for result video_url = self._poll_task(mv_client, task_id) - # Download and return video - return self._download_video(video_url) + return (video_url, task_id) def _poll_task(self, mv_client, task_id, max_retries=180): for i in range(max_retries): @@ -110,13 +91,6 @@ class ViduText2VideoNode: raise Exception("Task timed out") - def _download_video(self, video_url): - response = requests.get(video_url, timeout=120) - response.raise_for_status() - video_io = io.BytesIO(response.content) - video_io.seek(0) - return (VideoFromFile(video_io),) - NODE_CLASS_MAPPINGS = { "Vidu_Text2Video": ViduText2VideoNode, diff --git a/py/wan_ai_i2v.py b/py/wan_ai_i2v.py index f55feae..b0f99b0 100644 --- a/py/wan_ai_i2v.py +++ b/py/wan_ai_i2v.py @@ -1,27 +1,9 @@ import time -import requests -import io from .modelverse_api.client import ModelverseClient from .modelverse_api.utils import image_to_base64 from comfy.comfy_types.node_typing import IO -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 @@ -44,8 +26,8 @@ class Modelverse_WanAII2V: } } - RETURN_TYPES = (IO.VIDEO,) - RETURN_NAMES = ("video",) + RETURN_TYPES = (IO.STRING, IO.STRING) + RETURN_NAMES = ("url", "task_id") FUNCTION = "generate_video" CATEGORY = "UCLOUD_MODELVERSE" @@ -152,13 +134,8 @@ class Modelverse_WanAII2V: if not video_url: raise Exception(f"Task timed out after {max_retries * 5} seconds") - - # 3. Download video - response = requests.get(video_url, timeout=120) - response.raise_for_status() - video_io = io.BytesIO(response.content) - video_io.seek(0) - return (VideoFromFile(video_io),) + + return (video_url, task_id) NODE_CLASS_MAPPINGS = { diff --git a/py/wan_ai_t2v.py b/py/wan_ai_t2v.py index 4aaf6d8..e8fce43 100644 --- a/py/wan_ai_t2v.py +++ b/py/wan_ai_t2v.py @@ -1,25 +1,7 @@ import time -import requests -import io from .modelverse_api.client import ModelverseClient from comfy.comfy_types.node_typing import IO -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_WanAIT2V: def __init__(self): @@ -38,8 +20,8 @@ class Modelverse_WanAIT2V: } } - RETURN_TYPES = (IO.VIDEO,) - RETURN_NAMES = ("video",) + RETURN_TYPES = (IO.STRING, IO.STRING) + RETURN_NAMES = ("url", "task_id") FUNCTION = "generate_video" CATEGORY = "UCLOUD_MODELVERSE" @@ -86,13 +68,8 @@ class Modelverse_WanAIT2V: 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, timeout=120) - response.raise_for_status() - video_io = io.BytesIO(response.content) - video_io.seek(0) - return (VideoFromFile(video_io),) + + return (video_url, task_id) NODE_CLASS_MAPPINGS = { From fafef977a1ec8c55563678f9181b578d9de0cef1 Mon Sep 17 00:00:00 2001 From: "jason.mei" Date: Tue, 30 Dec 2025 10:47:38 +0800 Subject: [PATCH 08/12] refactor(preview_video): migrate to async API and use comfy video types - Replace requests library with comfy_api_nodes download utility for better integration - Convert run method to async for non-blocking video download operations - Update input/output types to use IO enum (IO.STRING, IO.BOOLEAN, IO.VIDEO) - Change return type from STRING file path to VIDEO object for direct video output - Simplify video handling by using download_url_to_video_output utility function - Add support for multiple video data access patterns (video_io, get_bytes, BytesIO) - Improve video saving logic with proper data extraction from video object - Remove unused os.path and re imports optimization for cleaner code - Maintains backward compatibility with save_output parameter for optional file persistence --- py/preview_video.py | 85 ++++++++++++++++++++++++++------------------- 1 file changed, 50 insertions(+), 35 deletions(-) diff --git a/py/preview_video.py b/py/preview_video.py index 956d3ab..83c8b09 100644 --- a/py/preview_video.py +++ b/py/preview_video.py @@ -1,7 +1,9 @@ import os import re -import requests import folder_paths +from comfy.comfy_types.node_typing import IO +from comfy_api_nodes.apis.client import ApiEndpoint, HttpMethod, SynchronousOperation +from comfy_api_nodes.util import download_url_to_video_output class ModelversePreviewVideo: @@ -9,9 +11,9 @@ class ModelversePreviewVideo: def INPUT_TYPES(cls): return { "required": { - "video_url": ("STRING", {"forceInput": True}), - "filename_prefix": ("STRING", {"default": "Modelverse"}), - "save_output": ("BOOLEAN", {"default": True}), + "video_url": (IO.STRING, {"forceInput": True}), + "filename_prefix": (IO.STRING, {"default": "Modelverse"}), + "save_output": (IO.BOOLEAN, {"default": True}), } } @@ -19,42 +21,55 @@ class ModelversePreviewVideo: FUNCTION = "run" CATEGORY = "UCLOUD_MODELVERSE" - RETURN_TYPES = ("STRING",) - RETURN_NAMES = ("file_path",) - - def run(self, video_url, filename_prefix, save_output): - if not save_output: - return {"ui": {"video_url": [video_url]}, "result": ('',)} - - output_dir = folder_paths.get_output_directory() - ( - full_output_folder, - filename, - _, - _, - _, - ) = folder_paths.get_save_image_path(filename_prefix, output_dir) - - max_counter = 0 - matcher = re.compile(f"{re.escape(filename)}_(\\d+)\\D*\\..+", re.IGNORECASE) - for existing_file in os.listdir(full_output_folder): - match = matcher.fullmatch(existing_file) - if match: - file_counter = int(match.group(1)) - if file_counter > max_counter: - max_counter = file_counter - - counter = max_counter + 1 - file = f"{filename}_{counter:05}.mp4" - file_path = os.path.join(full_output_folder, file) + RETURN_TYPES = (IO.VIDEO,) + RETURN_NAMES = ("video",) + async def run(self, video_url, filename_prefix, save_output): if type(video_url) == list: video_url = video_url[0] - response = requests.get(video_url, stream=True) - open(file_path, "wb").write(response.content) + # Download video and get VIDEO output + video = await download_url_to_video_output(video_url) - return {"ui": {"video_url": [video_url]}, "result": (file_path,)} + # Save to file if requested + if save_output: + output_dir = folder_paths.get_output_directory() + ( + full_output_folder, + filename, + _, + _, + _, + ) = folder_paths.get_save_image_path(filename_prefix, output_dir) + + max_counter = 0 + matcher = re.compile(f"{re.escape(filename)}_(\\d+)\\D*\\..+", re.IGNORECASE) + for existing_file in os.listdir(full_output_folder): + match = matcher.fullmatch(existing_file) + if match: + file_counter = int(match.group(1)) + if file_counter > max_counter: + max_counter = file_counter + + counter = max_counter + 1 + file = f"{filename}_{counter:05}.mp4" + file_path = os.path.join(full_output_folder, file) + + # Get video data from the video object and save + if hasattr(video, 'video_io'): + video_data = video.video_io.getvalue() + elif hasattr(video, 'get_bytes'): + video_data = video.get_bytes() + else: + # Try to read from the BytesIO + video.video_io.seek(0) + video_data = video.video_io.read() + video.video_io.seek(0) + + with open(file_path, "wb") as f: + f.write(video_data) + + return {"ui": {"video_url": [video_url]}, "result": (video,)} NODE_CLASS_MAPPINGS = { From 1f493b180e6c03d85a993f3df9f763cebcb4c773 Mon Sep 17 00:00:00 2001 From: "jason.mei" Date: Tue, 30 Dec 2025 10:49:51 +0800 Subject: [PATCH 09/12] refactor(preview_video): remove unused API client imports - Remove unused ApiEndpoint, HttpMethod, and SynchronousOperation imports - Simplify module dependencies by eliminating unnecessary API client references - Maintain existing functionality with cleaner import structure --- py/preview_video.py | 1 - 1 file changed, 1 deletion(-) diff --git a/py/preview_video.py b/py/preview_video.py index 83c8b09..e76d1e1 100644 --- a/py/preview_video.py +++ b/py/preview_video.py @@ -2,7 +2,6 @@ import os import re import folder_paths from comfy.comfy_types.node_typing import IO -from comfy_api_nodes.apis.client import ApiEndpoint, HttpMethod, SynchronousOperation from comfy_api_nodes.util import download_url_to_video_output From 4092be6a0aa086a5e7908da0b38322b761c5a678 Mon Sep 17 00:00:00 2001 From: "jason.mei" Date: Tue, 30 Dec 2025 11:02:47 +0800 Subject: [PATCH 10/12] refactor(preview_video): simplify video saving and reorder download logic - Add requests import for direct HTTP video downloads - Replace complex video object data extraction with direct URL download using requests.get() - Move file saving logic before download_url_to_video_output call to avoid consuming data stream - Simplify video data retrieval by downloading directly from URL instead of extracting from video object - Add timeout parameter (120s) and error handling for HTTP requests - Improve code clarity by removing conditional checks for different video object attributes --- py/preview_video.py | 24 ++++++++---------------- 1 file changed, 8 insertions(+), 16 deletions(-) diff --git a/py/preview_video.py b/py/preview_video.py index e76d1e1..15ffa1e 100644 --- a/py/preview_video.py +++ b/py/preview_video.py @@ -1,5 +1,6 @@ import os import re +import requests import folder_paths from comfy.comfy_types.node_typing import IO from comfy_api_nodes.util import download_url_to_video_output @@ -27,10 +28,7 @@ class ModelversePreviewVideo: if type(video_url) == list: video_url = video_url[0] - # Download video and get VIDEO output - video = await download_url_to_video_output(video_url) - - # Save to file if requested + # Save to file if requested (do this first before download_url_to_video_output consumes the data) if save_output: output_dir = folder_paths.get_output_directory() ( @@ -54,19 +52,13 @@ class ModelversePreviewVideo: file = f"{filename}_{counter:05}.mp4" file_path = os.path.join(full_output_folder, file) - # Get video data from the video object and save - if hasattr(video, 'video_io'): - video_data = video.video_io.getvalue() - elif hasattr(video, 'get_bytes'): - video_data = video.get_bytes() - else: - # Try to read from the BytesIO - video.video_io.seek(0) - video_data = video.video_io.read() - video.video_io.seek(0) - + response = requests.get(video_url, timeout=120) + response.raise_for_status() with open(file_path, "wb") as f: - f.write(video_data) + f.write(response.content) + + # Download video and get VIDEO output + video = await download_url_to_video_output(video_url) return {"ui": {"video_url": [video_url]}, "result": (video,)} From db0b8fa2666a14e76a0ec3e58eead309afbac6d5 Mon Sep 17 00:00:00 2001 From: "jason.mei" Date: Wed, 31 Dec 2025 10:02:52 +0800 Subject: [PATCH 11/12] feat(sora): add Sora2 text-to-video and image-to-video generation nodes - Add SoraText2VideoNode for OpenAI Sora2 text-to-video generation * Support both standard and Pro model variants * Configurable video size, duration, and prompt parameters * Pro version supports extended resolution options (1024x1792, 1792x1024) - Add SoraImg2VideoNode for OpenAI Sora2 image-to-video generation * Support both standard and Pro model variants * Accept first frame as image or URL input * Pro version supports 1080p resolution - Implement async task polling with configurable retry logic - Integrate with ModelverseClient for API communication - Add comprehensive input validation and error handling - Follow established video generation patterns from Vidu and Wan AI nodes --- py/sora_i2v.py | 122 +++++++++++++++++++++++++++++++++++++++++++++++++ py/sora_t2v.py | 99 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 221 insertions(+) create mode 100644 py/sora_i2v.py create mode 100644 py/sora_t2v.py diff --git a/py/sora_i2v.py b/py/sora_i2v.py new file mode 100644 index 0000000..934f511 --- /dev/null +++ b/py/sora_i2v.py @@ -0,0 +1,122 @@ +""" +OpenAI Sora2 Img2Video - 图生视频模型 +Models: openai/sora-2/image-to-video, openai/sora-2/image-to-video-pro +""" +import time +from .modelverse_api.client import ModelverseClient +from .modelverse_api.utils import image_to_base64 +from comfy.comfy_types.node_typing import IO + + +MODELS = ["openai/sora-2/image-to-video", "openai/sora-2/image-to-video-pro"] +RESOLUTIONS_PRO = ["720p", "1080p"] +DURATIONS = [4, 8, 12] + + +class SoraImg2VideoNode: + """ + OpenAI Sora2 Img2Video - 图生视频 + Models: image-to-video (普通版), image-to-video-pro (Pro版,支持1080p) + """ + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "client": ("MODELVERSE_API_CLIENT",), + "model": (MODELS, {"default": "openai/sora-2/image-to-video", "tooltip": "image-to-video: 普通版, image-to-video-pro: Pro版(支持1080p)"}), + }, + "optional": { + "first_frame_image": (IO.IMAGE, {"tooltip": "首帧图片"}), + "first_frame_url": (IO.STRING, {"default": "", "tooltip": "首帧图片URL (与image二选一)"}), + "prompt": (IO.STRING, {"multiline": True, "default": "", "tooltip": "提示词,用于指导视频生成"}), + "resolution": (RESOLUTIONS_PRO, {"default": "720p", "tooltip": "分辨率 (Pro版支持1080p)"}), + "duration": (DURATIONS, {"default": 4, "tooltip": "视频时长(秒): 4, 8, 12"}), + } + } + + RETURN_TYPES = (IO.STRING, IO.STRING) + RETURN_NAMES = ("url", "task_id") + FUNCTION = "generate" + CATEGORY = "UCLOUD_MODELVERSE/Sora" + + def generate(self, client, model, first_frame_image=None, first_frame_url="", + prompt="", resolution="720p", duration=4): + api_key = client.get("api_key") + if not api_key: + raise ValueError("API key is not set") + + # Validate resolution for non-pro model + if model == "openai/sora-2/image-to-video" and resolution == "1080p": + raise ValueError("普通版不支持 1080p,请使用 Pro 版") + + mv_client = ModelverseClient(api_key) + + # Validate first frame input + 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("请提供 first_frame_image 或 first_frame_url,不能同时提供") + if not has_url and not has_image: + raise ValueError("必须提供 first_frame_image 或 first_frame_url") + + task_input = {} + if has_url: + task_input["first_frame_url"] = first_frame_url.strip() + else: + task_input["first_frame_url"] = image_to_base64(first_frame_image) + + if prompt and prompt.strip(): + task_input["prompt"] = prompt.strip() + + parameters = { + "duration": duration, + } + + # Pro版支持resolution参数 + if model == "openai/sora-2/image-to-video-pro": + parameters["resolution"] = resolution + + # Submit task + submit_res = mv_client.submit_task(model, 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}") + + print(f"Sora I2V task submitted: {task_id}") + + # Poll for result + video_url = self._poll_task(mv_client, task_id) + + return (video_url, task_id) + + def _poll_task(self, mv_client, task_id, max_retries=180): + for i in range(max_retries): + 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: + return urls[0] + raise Exception("Task succeeded but no video URL returned") + elif task_status == "Failure": + error = status_res.get("output", {}).get("error_message", "Unknown error") + raise Exception(f"Task failed: {error}") + elif task_status in ["Pending", "Running"]: + print(f"Task {task_id}: {task_status} ({i+1}/{max_retries})") + time.sleep(5) + else: + raise Exception(f"Unknown status: {task_status}") + + raise Exception("Task timed out") + + +NODE_CLASS_MAPPINGS = { + "Sora_Img2Video": SoraImg2VideoNode, +} + +NODE_DISPLAY_NAME_MAPPINGS = { + "Sora_Img2Video": "Sora Img2Video", +} diff --git a/py/sora_t2v.py b/py/sora_t2v.py new file mode 100644 index 0000000..9b925f3 --- /dev/null +++ b/py/sora_t2v.py @@ -0,0 +1,99 @@ +""" +OpenAI Sora2 Text2Video - 文生视频模型 +Models: openai/sora-2/text-to-video, openai/sora-2/text-to-video-pro +""" +import time +from .modelverse_api.client import ModelverseClient +from comfy.comfy_types.node_typing import IO + + +MODELS = ["openai/sora-2/text-to-video", "openai/sora-2/text-to-video-pro"] +SIZES = ["720x1280", "1280x720"] +SIZES_PRO = ["720x1280", "1280x720", "1024x1792", "1792x1024"] +DURATIONS = [4, 8, 12] + + +class SoraText2VideoNode: + """ + OpenAI Sora2 Text2Video - 文生视频 + Models: text-to-video (普通版), text-to-video-pro (Pro版,支持更多分辨率) + """ + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "client": ("MODELVERSE_API_CLIENT",), + "model": (MODELS, {"default": "openai/sora-2/text-to-video", "tooltip": "text-to-video: 普通版, text-to-video-pro: Pro版"}), + "prompt": (IO.STRING, {"multiline": True, "default": "A beautiful girl is dancing", "tooltip": "提示词,用于指导视频生成"}), + }, + "optional": { + "size": (SIZES_PRO, {"default": "720x1280", "tooltip": "视频尺寸 (Pro版支持更多选项)"}), + "duration": (DURATIONS, {"default": 4, "tooltip": "视频时长(秒): 4, 8, 12"}), + } + } + + RETURN_TYPES = (IO.STRING, IO.STRING) + RETURN_NAMES = ("url", "task_id") + FUNCTION = "generate" + CATEGORY = "UCLOUD_MODELVERSE/Sora" + + def generate(self, client, model, prompt, size="720x1280", duration=4): + api_key = client.get("api_key") + if not api_key: + raise ValueError("API key is not set") + + # Validate size for non-pro model + if model == "openai/sora-2/text-to-video" and size not in SIZES: + raise ValueError(f"普通版仅支持 {SIZES},请使用 Pro 版获取更多分辨率选项") + + mv_client = ModelverseClient(api_key) + + task_input = {"prompt": prompt} + parameters = { + "size": size, + "duration": duration, + } + + # Submit task + submit_res = mv_client.submit_task(model, 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}") + + print(f"Sora T2V task submitted: {task_id}") + + # Poll for result + video_url = self._poll_task(mv_client, task_id) + + return (video_url, task_id) + + def _poll_task(self, mv_client, task_id, max_retries=180): + for i in range(max_retries): + 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: + return urls[0] + raise Exception("Task succeeded but no video URL returned") + elif task_status == "Failure": + error = status_res.get("output", {}).get("error_message", "Unknown error") + raise Exception(f"Task failed: {error}") + elif task_status in ["Pending", "Running"]: + print(f"Task {task_id}: {task_status} ({i+1}/{max_retries})") + time.sleep(5) + else: + raise Exception(f"Unknown status: {task_status}") + + raise Exception("Task timed out") + + +NODE_CLASS_MAPPINGS = { + "Sora_Text2Video": SoraText2VideoNode, +} + +NODE_DISPLAY_NAME_MAPPINGS = { + "Sora_Text2Video": "Sora Text2Video", +} From 89f0ef3fe62c5fac9293a85074117b7ad312ea85 Mon Sep 17 00:00:00 2001 From: "jason.mei" Date: Sun, 4 Jan 2026 13:55:14 +0800 Subject: [PATCH 12/12] chore: update version to 1.0.8 in pyproject.toml --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 516e818..afd24d7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "ucloud-comfyui" description = "This is a custom node for ComfyUI that allows you to use the UCloud API directly in ComfyUI. UCloud is a high-performance AI computing platform providing powerful image generation models and industry-leading inference speeds. It offers a wide range of AI model options, including popular text-to-image and image editing models, ready to use without local GPU setup." -version = "1.0.7" +version = "1.0.8" license = {file = "LICENSE"} dependencies = ["requests", "pillow"]