Merge pull request #7 from yinjiajiu/main

feature:添加Kling,seedance,veo,happyhorse,vidu和Gemini系列模型,增加api key配置,防止泄漏
This commit is contained in:
Jianfeng Mei
2026-06-11 14:46:56 +08:00
committed by GitHub
39 changed files with 2263 additions and 85 deletions
+2 -1
View File
@@ -6,4 +6,5 @@ py/modelverse_api/requests/__pycache__
/venv
.idea/
.ace-tool/
docs
docs
secrets.json
+20 -4
View File
@@ -15,13 +15,13 @@ This plugin provides seamless integration with UCloud's ModelVerse API, enabling
2. **Configure API Key**
- Get your API key: [Console En](https://console.ucloud-global.com/modelverse/experience/api-keys), [Console Zh](https://console.ucloud.cn/modelverse/experience/api-keys)
- Add your UCloud ModelVerse API key in the `Modelverse Client` node
- Alternatively, create a `config.ini` file with `MODELVERSE_API_KEY=your_api_key`
- Get your API key: [Console En](https://astraflow.ucloud.cn/modelverse/api-keys), [Console Zh](https://astraflow.ucloud.cn/modelverse/api-keys)
- Add your UCloud ModelVerse API key in the `Modelverse Secret Client` node
- Or Alternatively, create a `config.ini` file with `MODELVERSE_API_KEY=your_api_key`
### Basic Usage
1. Add a `Modelverse Client` node to your workflow
1. Add a `Modelverse Secret Client` node to your workflow
2. Choose your desired model from the supported models below
3. Connect the appropriate input/output nodes
4. Run your workflow
@@ -30,6 +30,8 @@ This plugin provides seamless integration with UCloud's ModelVerse API, enabling
Our plugin supports the following models, each optimized for specific use cases:
### Image Generation
- **Flux Dev** - Foundation model with strong stylization capabilities
- **Flux Kontext Pro Text2Image** - Specialized for text-to-image generation
- **Flux Kontext Pro** - Expert single image editing and enhancement
@@ -38,6 +40,20 @@ Our plugin supports the following models, each optimized for specific use cases:
- **Flux Kontext Max** - Flagship single image editing with advanced features
- **Flux Kontext Max (Multi-inputs)** - Most powerful multi-image processing
- **Step1X-Edit** - Precision local editing and inpainting
- **GPT Image 1** - OpenAI's text-to-image generation
- **Gemini 3 Pro Image** - Google's professional asset production with search grounding
- **Qwen Image** - Alibaba's text-to-image generation
### Video Generation
- **Kling V3** - Unified text/image-to-video and motion control
- **Kling V3 Omni** - Multimodal video generation with reference inputs
- **Doubao Seedance 2.0** - Text/image-to-video with audio generation
- **OpenAI Sora 2** - High-quality text-to-video generation
- **HappyHorse 1.0** - Text-to-video generation
- **Veo 3.1** - Google's video generation (text-to-video, image-to-video)
- **Vidu** - Multiple models for text/image-to-video (viduq3-pro, viduq3-turbo, viduq2)
- **Wan-AI** - Text/image-to-video generation
Note: (Multi-inputs) models use the same node interface as their single-input counterparts. Check the example workflows below for implementation details.
+141
View File
@@ -0,0 +1,141 @@
"""
Doubao Seedance 2.0 - Text/image-to-video model
"""
import time
from .modelverse_api.client import ModelverseClient
from .modelverse_api.requests.doubao_seedance_2 import (
DoubaoSeedance2,
MODEL,
RESOLUTIONS,
RATIOS,
)
from comfy.comfy_types.node_typing import IO
class DoubaoSeedance2Node:
"""
Doubao Seedance 2.0 text/image-to-video generation.
Supports text, first/last frame images, reference image/video/audio inputs.
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"client": ("MODELVERSE_API_CLIENT",),
"prompt": (IO.STRING, {
"multiline": True,
"default": "",
"tooltip": "Text prompt describing the desired video (recommended: up to 500 characters)",
}),
},
"optional": {
"first_frame_image": (IO.IMAGE, {"tooltip": "First frame image"}),
"first_frame_url": (IO.STRING, {"default": "", "tooltip": "First frame image URL (use either this OR first_frame_image, not both)"}),
"last_frame_image": (IO.IMAGE, {"tooltip": "Last frame image"}),
"last_frame_url": (IO.STRING, {"default": "", "tooltip": "Last frame image URL (use either this OR last_frame_image, not both)"}),
"reference_image": (IO.IMAGE, {"tooltip": "Reference image"}),
"reference_image_url": (IO.STRING, {"default": "", "tooltip": "Reference image URL (use either this OR reference_image, not both)"}),
"reference_video_url": (IO.STRING, {"default": "", "tooltip": "Reference video URL"}),
"reference_audio_url": (IO.STRING, {"default": "", "tooltip": "Reference audio URL"}),
"duration": (IO.INT, {"default": 5, "min": 4, "max": 15, "step": 1, "tooltip": "Video duration in seconds (4–15)"}),
"resolution": (RESOLUTIONS, {"default": "720p", "tooltip": "Output resolution; draft mode supports 480p only"}),
"ratio": (RATIOS, {"default": "adaptive", "tooltip": "Aspect ratio; adaptive auto-selects the best fit"}),
"seed": (IO.INT, {"default": 0, "min": 0, "max": 2147483647, "tooltip": "Random seed for reproducible results"}),
"generate_audio": (IO.BOOLEAN, {"default": False, "tooltip": "Generate audio synchronized with the video"}),
"camera_fixed": (IO.BOOLEAN, {"default": False, "tooltip": "Fix camera position (no camera movement)"}),
"watermark": (IO.BOOLEAN, {"default": False, "tooltip": "Add watermark to the output video"}),
"draft": (IO.BOOLEAN, {"default": False, "tooltip": "Draft/preview mode (480p only)"}),
},
}
RETURN_TYPES = (IO.STRING, IO.STRING)
RETURN_NAMES = ("url", "task_id")
FUNCTION = "generate"
CATEGORY = "UCLOUD_MODELVERSE/Seedance"
def generate(
self,
client,
prompt,
first_frame_image=None,
first_frame_url="",
last_frame_image=None,
last_frame_url="",
reference_image=None,
reference_image_url="",
reference_video_url="",
reference_audio_url="",
duration=5,
resolution="720p",
ratio="adaptive",
seed=0,
generate_audio=False,
camera_fixed=False,
watermark=False,
draft=False,
):
api_key = client.get("api_key")
if not api_key:
raise ValueError("API key is not set")
request = DoubaoSeedance2(
prompt=prompt,
first_frame=first_frame_image,
first_frame_url=first_frame_url,
last_frame=last_frame_image,
last_frame_url=last_frame_url,
reference_image=reference_image,
reference_image_url=reference_image_url,
reference_video_url=reference_video_url,
reference_audio_url=reference_audio_url,
duration=duration,
resolution=resolution,
ratio=ratio,
seed=seed,
generate_audio=generate_audio,
camera_fixed=camera_fixed,
watermark=watermark,
draft=draft,
)
mv_client = ModelverseClient(api_key)
print(f"Submitting Seedance 2.0 task: model={MODEL}, prompt={prompt!r}")
submit_res = mv_client.submit_task_request(request)
task_id = submit_res.get("output", {}).get("task_id")
if not task_id:
raise Exception(f"Failed to submit task: {submit_res}")
print(f"Seedance 2.0 task submitted: {task_id}")
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")
if task_status == "Failure":
error = status_res.get("output", {}).get("error_message", "Unknown error")
raise Exception(f"Task failed: {error}")
if task_status in ["Pending", "Running"]:
print(f"Task {task_id}: {task_status} ({i + 1}/{max_retries})")
time.sleep(5)
continue
raise Exception(f"Unknown status: {task_status}")
raise Exception("Task timed out")
NODE_CLASS_MAPPINGS = {
"Doubao_Seedance_2": DoubaoSeedance2Node,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"Doubao_Seedance_2": "Modelverse Doubao Seedance 2.0",
}
+1 -1
View File
@@ -94,7 +94,7 @@ class FluxDevNode:
RETURN_TYPES = ("IMAGE", )
RETURN_NAMES = ("image",)
CATEGORY = "UCLOUD_MODELVERSE"
CATEGORY = "UCLOUD_MODELVERSE/Flux"
FUNCTION = "execute"
async def execute(self,
+11 -4
View File
@@ -22,8 +22,8 @@ class FluxKontextMaxNode:
"required": {
"client": ("MODELVERSE_API_CLIENT",),
"prompt": (IO.STRING, {"multiline": True, "default": "", "tooltip": "Text description of the image to generate"}),
"images": ("IMAGE_LIST", {
"tooltip": "The image(s) to edit from. If not included, use text-to-image mode.",
"images": (IO.IMAGE, {
"tooltip": "Image(s) to edit. Connect a single IMAGE, or use Modelverse Image Packer for multiple images.",
"forceInput": False,
"default": None
}),
@@ -74,9 +74,16 @@ class FluxKontextMaxNode:
mode = "single"
if isinstance(images, list):
if len(images) > 1:
print("INFO:", "Running Flux Kontext Max multi-image edit mode.")
print("INFO:", f"{len(images)} image included for the multi-image edit.")
mode = "multi"
else:
images = images[0]
elif isinstance(images, torch.Tensor) and images.ndim == 4 and images.shape[0] > 1:
images = [images[i:i + 1] for i in range(images.shape[0])]
print("INFO:", "Running Flux Kontext Max multi-image edit mode.")
print(
"INFO:", f"{len(images)} image included for the multi-image edit.")
print("INFO:", f"{len(images)} image included for the multi-image edit.")
mode = "multi"
else:
print("INFO:", "Running Flux Kontext Max single-image edit mode.")
+1 -1
View File
@@ -59,7 +59,7 @@ class FluxKontextMaxT2INode:
RETURN_TYPES = ("IMAGE", )
RETURN_NAMES = ("image",)
CATEGORY = "UCLOUD_MODELVERSE"
CATEGORY = "UCLOUD_MODELVERSE/Flux"
FUNCTION = "execute"
async def execute(self,
+12 -5
View File
@@ -22,8 +22,8 @@ class FluxKontextProNode:
"required": {
"client": ("MODELVERSE_API_CLIENT",),
"prompt": (IO.STRING, {"multiline": True, "default": "", "tooltip": "Text description of the image to generate"}),
"images": ("IMAGE_LIST", {
"tooltip": "The image(s) to edit from. If not included, use text-to-image mode.",
"images": (IO.IMAGE, {
"tooltip": "Image(s) to edit. Connect a single IMAGE, or use Modelverse Image Packer for multiple images.",
"forceInput": False,
"default": None
}),
@@ -56,7 +56,7 @@ class FluxKontextProNode:
RETURN_TYPES = ("IMAGE", )
RETURN_NAMES = ("image",)
CATEGORY = "UCLOUD_MODELVERSE"
CATEGORY = "UCLOUD_MODELVERSE/Flux"
FUNCTION = "execute"
async def execute(self,
@@ -74,9 +74,16 @@ class FluxKontextProNode:
mode = "single"
if isinstance(images, list):
if len(images) > 1:
print("INFO:", "Running Flux Kontext Pro multi-image edit mode.")
print("INFO:", f"{len(images)} image included for the multi-image edit.")
mode = "multi"
else:
images = images[0]
elif isinstance(images, torch.Tensor) and images.ndim == 4 and images.shape[0] > 1:
images = [images[i:i + 1] for i in range(images.shape[0])]
print("INFO:", "Running Flux Kontext Pro multi-image edit mode.")
print(
"INFO:", f"{len(images)} image included for the multi-image edit.")
print("INFO:", f"{len(images)} image included for the multi-image edit.")
mode = "multi"
else:
print("INFO:", "Running Flux Kontext Pro single-image edit mode.")
+1 -1
View File
@@ -60,7 +60,7 @@ class FluxKontextProT2INode:
RETURN_TYPES = ("IMAGE", )
RETURN_NAMES = ("image",)
CATEGORY = "UCLOUD_MODELVERSE"
CATEGORY = "UCLOUD_MODELVERSE/Flux"
FUNCTION = "execute"
async def execute(self,
+10 -5
View File
@@ -8,6 +8,9 @@ from .modelverse_api.requests.gemini_flash_image import GeminiFlashImageRequest
from .modelverse_api.utils import decode_image, images2tensor
MODELS = ["gemini-3.1-flash-image", "gemini-2.5-flash-image"]
def _extract_images_from_gemini_response(resp: Dict[str, Any]) -> List[torch.Tensor]:
"""Parse Gemini API response and return list of tensors for any inline images."""
images = []
@@ -36,9 +39,9 @@ def _extract_images_from_gemini_response(resp: Dict[str, Any]) -> List[torch.Ten
class GeminiFlashImageNode:
"""
Gemini 2.5 Flash Image (text-to-image and image-edit) via Modelverse API.
Gemini Flash Image (text-to-image and image-edit) via Modelverse API.
Endpoint: /v1beta/models/gemini-2.5-flash-image:generateContent
Endpoint: /v1beta/models/{model}:generateContent
"""
@classmethod
@@ -46,6 +49,7 @@ class GeminiFlashImageNode:
return {
"required": {
"client": ("MODELVERSE_API_CLIENT",),
"model": (MODELS, {"default": "gemini-3.1-flash-image", "tooltip": "Gemini Flash Image model"}),
"prompt": (IO.STRING, {"multiline": True, "default": "Create a picture of a nano banana dish in a fancy restaurant with a Gemini theme"}),
"mime_type": (["image/png", "image/jpeg"], {"default": "image/png"}),
"num_requests": (IO.INT, {"default": 1, "min": 1, "max": 10, "step": 1, "display": "number"}),
@@ -57,11 +61,12 @@ class GeminiFlashImageNode:
RETURN_TYPES = (IO.IMAGE,)
RETURN_NAMES = ("image",)
CATEGORY = "UCLOUD_MODELVERSE"
CATEGORY = "UCLOUD_MODELVERSE/Gemini"
FUNCTION = "execute"
async def execute(self,
client,
model: str,
prompt: str,
mime_type: str = "image/png",
num_requests: int = 1,
@@ -73,7 +78,7 @@ class GeminiFlashImageNode:
outputs: List[torch.Tensor] = []
for i in range(num_requests):
req = GeminiFlashImageRequest(prompt=prompt, image=image, mime_type=mime_type)
req = GeminiFlashImageRequest(prompt=prompt, model=model, image=image, mime_type=mime_type)
payload = req.build_payload()
resp = mv_client.post(req.API_PATH, payload)
@@ -101,5 +106,5 @@ NODE_CLASS_MAPPINGS = {
}
NODE_DISPLAY_NAME_MAPPINGS = {
"NanoBanana": "Modelverse Gemini 2.5 Flash Image",
"NanoBanana": "Modelverse Gemini Flash Image",
}
+6 -6
View File
@@ -1,5 +1,5 @@
"""
Gemini 3 Pro Image Preview (Nano Banana Pro) ComfyUI Node.
Gemini 3 Pro Image (Nano Banana Pro) ComfyUI Node.
Professional asset production with advanced features:
- High-resolution output (1K, 2K, 4K)
- Aspect ratio control
@@ -45,7 +45,7 @@ IMAGE_SIZES = ["1K", "2K", "4K"]
class GeminiProImageNode:
"""
Gemini 3 Pro Image Preview (Nano Banana Pro) via Modelverse API.
Gemini 3 Pro Image (Nano Banana Pro) via Modelverse API.
Professional asset production with:
- High-resolution output (1K, 2K, 4K)
@@ -53,7 +53,7 @@ class GeminiProImageNode:
- Google Search grounding for real-time info
- Up to 14 reference images
Endpoint: /v1beta/models/gemini-3-pro-image-preview:generateContent
Endpoint: /v1beta/models/gemini-3-pro-image:generateContent
"""
@classmethod
@@ -79,7 +79,7 @@ class GeminiProImageNode:
RETURN_TYPES = (IO.IMAGE,)
RETURN_NAMES = ("image",)
CATEGORY = "UCLOUD_MODELVERSE"
CATEGORY = "UCLOUD_MODELVERSE/Gemini"
FUNCTION = "execute"
async def execute(
@@ -135,7 +135,7 @@ class GeminiProImageNode:
outputs.append(tensors[0])
if not outputs:
raise Exception("No images generated from Gemini 3 Pro Image Preview")
raise Exception("No images generated from Gemini 3 Pro Image")
return (torch.cat(outputs, dim=0),)
@@ -145,5 +145,5 @@ NODE_CLASS_MAPPINGS = {
}
NODE_DISPLAY_NAME_MAPPINGS = {
'Gemini Pro Image (Nano Banana Pro)': 'Gemini 3 Pro Image Preview',
'Gemini Pro Image (Nano Banana Pro)': 'Gemini 3 Pro Image',
}
+1 -1
View File
@@ -32,7 +32,7 @@ class GPTImage1Node:
RETURN_TYPES = (IO.IMAGE,)
RETURN_NAMES = ("image",)
CATEGORY = "UCLOUD_MODELVERSE"
CATEGORY = "UCLOUD_MODELVERSE/Gpt-img"
FUNCTION = "execute"
async def execute(
+1 -1
View File
@@ -34,7 +34,7 @@ class GPTImage1EditNode:
RETURN_TYPES = (IO.IMAGE,)
RETURN_NAMES = ("image",)
CATEGORY = "UCLOUD_MODELVERSE"
CATEGORY = "UCLOUD_MODELVERSE/Gpt-img"
FUNCTION = "execute"
async def execute(
+138
View File
@@ -0,0 +1,138 @@
"""
HappyHorse 1.0 Image2Video
Model: happyhorse-1.0-i2v
"""
import time
from .modelverse_api.client import ModelverseClient
from .modelverse_api.utils import image_to_base64
from comfy.comfy_types.node_typing import IO
MODEL = "happyhorse-1.0-i2v"
RESOLUTIONS = ["480P", "720P", "1080P"]
class HappyHorseImg2VideoNode:
"""
HappyHorse 1.0 Image2Video - image-to-video generation.
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"client": ("MODELVERSE_API_CLIENT",),
},
"optional": {
"first_frame_image": (IO.IMAGE, {"tooltip": "First frame image"}),
"first_frame_url": (IO.STRING, {
"default": "",
"tooltip": "First frame image URL or base64 (use either this OR first_frame_image)",
}),
"prompt": (IO.STRING, {
"multiline": True,
"default": "",
"tooltip": "Optional text prompt to guide video generation",
}),
"resolution": (RESOLUTIONS, {
"default": "720P",
"tooltip": "Output resolution tier",
}),
"duration": (IO.INT, {
"default": 5, "min": 1, "max": 15, "step": 1,
"tooltip": "Video duration in seconds",
}),
"seed": (IO.INT, {
"default": 0, "min": 0, "max": 2147483647,
"tooltip": "Random seed (0 for random)",
}),
"watermark": (IO.BOOLEAN, {
"default": False,
"tooltip": "Add watermark to output video",
}),
},
}
RETURN_TYPES = (IO.STRING, IO.STRING)
RETURN_NAMES = ("url", "task_id")
FUNCTION = "generate"
CATEGORY = "UCLOUD_MODELVERSE/HappyHorse"
def generate(
self,
client,
first_frame_image=None,
first_frame_url="",
prompt="",
resolution="720P",
duration=5,
seed=0,
watermark=False,
):
api_key = client.get("api_key")
if not api_key:
raise ValueError("API key is not set")
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("Provide either first_frame_image or first_frame_url, not both")
if not has_url and not has_image:
raise ValueError("first_frame_image or first_frame_url is required for HappyHorse I2V")
mv_client = ModelverseClient(api_key)
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 = {
"resolution": resolution,
"duration": duration,
"seed": seed,
}
if watermark:
parameters["watermark"] = True
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"HappyHorse I2V task submitted: {task_id}")
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")
if task_status == "Failure":
error = status_res.get("output", {}).get("error_message", "Unknown error")
raise Exception(f"Task failed: {error}")
if task_status in ["Pending", "Running"]:
print(f"Task {task_id}: {task_status} ({i + 1}/{max_retries})")
time.sleep(5)
continue
raise Exception(f"Unknown status: {task_status}")
raise Exception("Task timed out")
NODE_CLASS_MAPPINGS = {
"HappyHorse_Img2Video": HappyHorseImg2VideoNode,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"HappyHorse_Img2Video": "Modelverse HappyHorse I2V",
}
+156
View File
@@ -0,0 +1,156 @@
"""
HappyHorse 1.0 Reference2Video
Model: happyhorse-1.0-r2v
"""
import time
from .modelverse_api.client import ModelverseClient
from .modelverse_api.utils import image_to_base64
from comfy.comfy_types.node_typing import IO
MODEL = "happyhorse-1.0-r2v"
RESOLUTIONS = ["480P", "720P", "1080P"]
RATIOS = ["16:9", "9:16", "1:1", "3:4", "4:3"]
class HappyHorseReference2VideoNode:
"""
HappyHorse 1.0 Reference2Video - reference-image-to-video generation.
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"client": ("MODELVERSE_API_CLIENT",),
"prompt": (IO.STRING, {
"multiline": True,
"default": "",
"tooltip": "Text prompt describing the desired video",
}),
},
"optional": {
"image1": (IO.IMAGE, {"tooltip": "Reference image 1"}),
"image2": (IO.IMAGE, {"tooltip": "Reference image 2"}),
"image3": (IO.IMAGE, {"tooltip": "Reference image 3"}),
"image4": (IO.IMAGE, {"tooltip": "Reference image 4"}),
"image_urls": (IO.STRING, {
"default": "",
"multiline": True,
"tooltip": "Reference image URLs, one per line",
}),
"resolution": (RESOLUTIONS, {
"default": "720P",
"tooltip": "Output resolution tier",
}),
"ratio": (RATIOS, {
"default": "16:9",
"tooltip": "Aspect ratio",
}),
"duration": (IO.INT, {
"default": 5, "min": 1, "max": 15, "step": 1,
"tooltip": "Video duration in seconds",
}),
"seed": (IO.INT, {
"default": 0, "min": 0, "max": 2147483647,
"tooltip": "Random seed (0 for random)",
}),
"watermark": (IO.BOOLEAN, {
"default": False,
"tooltip": "Add watermark to output video",
}),
},
}
RETURN_TYPES = (IO.STRING, IO.STRING)
RETURN_NAMES = ("url", "task_id")
FUNCTION = "generate"
CATEGORY = "UCLOUD_MODELVERSE/HappyHorse"
def generate(
self,
client,
prompt,
image1=None,
image2=None,
image3=None,
image4=None,
image_urls="",
resolution="720P",
ratio="16:9",
duration=5,
seed=0,
watermark=False,
):
api_key = client.get("api_key")
if not api_key:
raise ValueError("API key is not set")
if not prompt or not prompt.strip():
raise ValueError("prompt is required for HappyHorse R2V")
images = []
for img in [image1, image2, image3, image4]:
if img is not None:
images.append(image_to_base64(img))
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("At least one reference image is required for HappyHorse R2V")
mv_client = ModelverseClient(api_key)
task_input = {
"prompt": prompt.strip(),
"images": images,
}
parameters = {
"resolution": resolution,
"ratio": ratio,
"duration": duration,
"seed": seed,
}
if watermark:
parameters["watermark"] = True
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"HappyHorse R2V task submitted: {task_id}")
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")
if task_status == "Failure":
error = status_res.get("output", {}).get("error_message", "Unknown error")
raise Exception(f"Task failed: {error}")
if task_status in ["Pending", "Running"]:
print(f"Task {task_id}: {task_status} ({i + 1}/{max_retries})")
time.sleep(5)
continue
raise Exception(f"Unknown status: {task_status}")
raise Exception("Task timed out")
NODE_CLASS_MAPPINGS = {
"HappyHorse_Reference2Video": HappyHorseReference2VideoNode,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"HappyHorse_Reference2Video": "Modelverse HappyHorse R2V",
}
+124
View File
@@ -0,0 +1,124 @@
"""
HappyHorse 1.0 Text2Video
Model: happyhorse-1.0-t2v
"""
import time
from .modelverse_api.client import ModelverseClient
from comfy.comfy_types.node_typing import IO
MODEL = "happyhorse-1.0-t2v"
RESOLUTIONS = ["480P", "720P", "1080P"]
RATIOS = ["16:9", "9:16", "1:1", "3:4", "4:3"]
class HappyHorseText2VideoNode:
"""
HappyHorse 1.0 Text2Video - text-to-video generation.
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"client": ("MODELVERSE_API_CLIENT",),
"prompt": (IO.STRING, {
"multiline": True,
"default": "",
"tooltip": "Text prompt describing the desired video",
}),
},
"optional": {
"resolution": (RESOLUTIONS, {
"default": "720P",
"tooltip": "Output resolution tier",
}),
"ratio": (RATIOS, {
"default": "16:9",
"tooltip": "Aspect ratio",
}),
"duration": (IO.INT, {
"default": 5, "min": 1, "max": 15, "step": 1,
"tooltip": "Video duration in seconds",
}),
"seed": (IO.INT, {
"default": 0, "min": 0, "max": 2147483647,
"tooltip": "Random seed (0 for random)",
}),
"watermark": (IO.BOOLEAN, {
"default": False,
"tooltip": "Add watermark to output video",
}),
},
}
RETURN_TYPES = (IO.STRING, IO.STRING)
RETURN_NAMES = ("url", "task_id")
FUNCTION = "generate"
CATEGORY = "UCLOUD_MODELVERSE/HappyHorse"
def generate(
self,
client,
prompt,
resolution="720P",
ratio="16:9",
duration=5,
seed=0,
watermark=False,
):
api_key = client.get("api_key")
if not api_key:
raise ValueError("API key is not set")
if not prompt or not prompt.strip():
raise ValueError("prompt is required for HappyHorse T2V")
mv_client = ModelverseClient(api_key)
task_input = {"prompt": prompt.strip()}
parameters = {
"resolution": resolution,
"ratio": ratio,
"duration": duration,
"seed": seed,
}
if watermark:
parameters["watermark"] = True
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"HappyHorse T2V task submitted: {task_id}")
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")
if task_status == "Failure":
error = status_res.get("output", {}).get("error_message", "Unknown error")
raise Exception(f"Task failed: {error}")
if task_status in ["Pending", "Running"]:
print(f"Task {task_id}: {task_status} ({i + 1}/{max_retries})")
time.sleep(5)
continue
raise Exception(f"Unknown status: {task_status}")
raise Exception("Task timed out")
NODE_CLASS_MAPPINGS = {
"HappyHorse_Text2Video": HappyHorseText2VideoNode,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"HappyHorse_Text2Video": "Modelverse HappyHorse T2V",
}
+167
View File
@@ -0,0 +1,167 @@
"""
Kling V3 - Unified text/image-to-video and motion control model
"""
import time
from .modelverse_api.client import ModelverseClient
from .modelverse_api.requests.kling_common import (
ASPECT_RATIOS,
CHARACTER_ORIENTATIONS,
KLING_V3_TYPES,
MODEL_KLING_V3,
MODES,
SOUNDS,
YES_NO,
)
from .modelverse_api.requests.kling_v3 import KlingV3
from comfy.comfy_types.node_typing import IO
class KlingV3Node:
"""
Kling V3 unified video generation.
Auto-routes to text-to-video, image-to-video, or motion control.
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"client": ("MODELVERSE_API_CLIENT",),
"prompt": (IO.STRING, {
"multiline": True,
"default": "",
"tooltip": "Text prompt describing the desired video",
}),
},
"optional": {
"negative_prompt": (IO.STRING, {
"multiline": True,
"default": "",
"tooltip": "Negative prompt to avoid unwanted content",
}),
"kling_v3_type": (KLING_V3_TYPES, {
"default": "auto",
"tooltip": "Task type; auto infers from inputs (image→i2v, video→motion_control)",
}),
"first_frame_image": (IO.IMAGE, {"tooltip": "First frame image (image-to-video / motion control)"}),
"first_frame_url": (IO.STRING, {
"default": "",
"tooltip": "First frame URL (use either this OR first_frame_image, not both)",
}),
"last_frame_image": (IO.IMAGE, {"tooltip": "Last frame image (image-to-video)"}),
"last_frame_url": (IO.STRING, {
"default": "",
"tooltip": "Last frame URL (use either this OR last_frame_image, not both)",
}),
"reference_video_url": (IO.STRING, {
"default": "",
"tooltip": "Reference video URL (motion control; auto-selects motion_control when set)",
}),
"aspect_ratio": (ASPECT_RATIOS, {"default": "16:9", "tooltip": "Output aspect ratio"}),
"duration": (IO.INT, {
"default": 5, "min": 3, "max": 15, "step": 1,
"tooltip": "Video duration in seconds (3–15)",
}),
"mode": (MODES, {"default": "std", "tooltip": "std: 720p, pro: 1080p"}),
"sound": (SOUNDS, {"default": "off", "tooltip": "Generate synchronized audio"}),
"shot_type": (IO.STRING, {
"default": "",
"tooltip": "Optional shot type, e.g. 'multi' for multi-shot",
}),
"character_orientation": (CHARACTER_ORIENTATIONS, {
"default": "image",
"tooltip": "Motion control: follow image or video orientation",
}),
"keep_original_sound": (YES_NO, {
"default": "no",
"tooltip": "Motion control: keep original reference video sound",
}),
},
}
RETURN_TYPES = (IO.STRING, IO.STRING)
RETURN_NAMES = ("url", "task_id")
FUNCTION = "generate"
CATEGORY = "UCLOUD_MODELVERSE/Kling"
def generate(
self,
client,
prompt,
negative_prompt="",
kling_v3_type="auto",
first_frame_image=None,
first_frame_url="",
last_frame_image=None,
last_frame_url="",
reference_video_url="",
aspect_ratio="16:9",
duration=5,
mode="std",
sound="off",
shot_type="",
character_orientation="image",
keep_original_sound="no",
):
api_key = client.get("api_key")
if not api_key:
raise ValueError("API key is not set")
request = KlingV3(
prompt=prompt,
negative_prompt=negative_prompt,
kling_v3_type=kling_v3_type,
first_frame=first_frame_image,
first_frame_url=first_frame_url,
last_frame=last_frame_image,
last_frame_url=last_frame_url,
reference_video_url=reference_video_url,
aspect_ratio=aspect_ratio,
duration=duration,
mode=mode,
sound=sound,
shot_type=shot_type,
character_orientation=character_orientation,
keep_original_sound=keep_original_sound,
)
mv_client = ModelverseClient(api_key)
print(f"Submitting Kling V3 task: model={MODEL_KLING_V3}, type={kling_v3_type}, prompt={prompt!r}")
submit_res = mv_client.submit_task_request(request)
task_id = submit_res.get("output", {}).get("task_id")
if not task_id:
raise Exception(f"Failed to submit task: {submit_res}")
print(f"Kling V3 task submitted: {task_id}")
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")
if task_status == "Failure":
error = status_res.get("output", {}).get("error_message", "Unknown error")
raise Exception(f"Task failed: {error}")
if task_status in ["Pending", "Running"]:
print(f"Task {task_id}: {task_status} ({i + 1}/{max_retries})")
time.sleep(5)
continue
raise Exception(f"Unknown status: {task_status}")
raise Exception("Task timed out")
NODE_CLASS_MAPPINGS = {
"Kling_V3": KlingV3Node,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"Kling_V3": "Modelverse Kling V3",
}
+166
View File
@@ -0,0 +1,166 @@
"""
Kling V3 Omni - Multimodal video generation and editing model
"""
import time
from .modelverse_api.client import ModelverseClient
from .modelverse_api.requests.kling_common import (
ASPECT_RATIOS,
MODEL_KLING_V3_OMNI,
MODES,
REFER_TYPES,
SOUNDS,
YES_NO,
)
from .modelverse_api.requests.kling_v3_omni import KlingV3Omni
from comfy.comfy_types.node_typing import IO
class KlingV3OmniNode:
"""
Kling V3 Omni multimodal video generation.
Supports reference images, videos, and element library IDs.
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"client": ("MODELVERSE_API_CLIENT",),
"prompt": (IO.STRING, {
"multiline": True,
"default": "",
"tooltip": "Text prompt describing the desired video",
}),
},
"optional": {
"negative_prompt": (IO.STRING, {
"multiline": True,
"default": "",
"tooltip": "Negative prompt to avoid unwanted content",
}),
"first_frame_image": (IO.IMAGE, {"tooltip": "First frame reference image"}),
"first_frame_url": (IO.STRING, {
"default": "",
"tooltip": "First frame URL (use either this OR first_frame_image, not both)",
}),
"last_frame_image": (IO.IMAGE, {"tooltip": "End frame reference image"}),
"last_frame_url": (IO.STRING, {
"default": "",
"tooltip": "End frame URL (use either this OR last_frame_image, not both)",
}),
"reference_video_url": (IO.STRING, {
"default": "",
"tooltip": "Reference video URL for editing or style reference",
}),
"refer_type": (REFER_TYPES, {
"default": "feature",
"tooltip": "feature: style/motion reference; base: video editing",
}),
"keep_original_sound": (YES_NO, {
"default": "no",
"tooltip": "Keep original audio from reference video",
}),
"element_id": (IO.INT, {
"default": 0, "min": 0,
"tooltip": "Kling element library ID (0 to skip)",
}),
"aspect_ratio": (ASPECT_RATIOS, {
"default": "16:9",
"tooltip": "Output aspect ratio (required without first-frame or video editing)",
}),
"duration": (IO.INT, {
"default": 5, "min": 3, "max": 15, "step": 1,
"tooltip": "Video duration in seconds (3–15)",
}),
"mode": (MODES, {"default": "std", "tooltip": "std: 720p, pro: 1080p"}),
"sound": (SOUNDS, {
"default": "off",
"tooltip": "Generate synchronized audio (must be off when reference_video_url is set)",
}),
},
}
RETURN_TYPES = (IO.STRING, IO.STRING)
RETURN_NAMES = ("url", "task_id")
FUNCTION = "generate"
CATEGORY = "UCLOUD_MODELVERSE/Kling"
def generate(
self,
client,
prompt,
negative_prompt="",
first_frame_image=None,
first_frame_url="",
last_frame_image=None,
last_frame_url="",
reference_video_url="",
refer_type="feature",
keep_original_sound="no",
element_id=0,
aspect_ratio="16:9",
duration=5,
mode="std",
sound="off",
):
api_key = client.get("api_key")
if not api_key:
raise ValueError("API key is not set")
request = KlingV3Omni(
prompt=prompt,
negative_prompt=negative_prompt,
first_frame=first_frame_image,
first_frame_url=first_frame_url,
last_frame=last_frame_image,
last_frame_url=last_frame_url,
reference_video_url=reference_video_url,
refer_type=refer_type,
keep_original_sound=keep_original_sound,
element_id=element_id,
aspect_ratio=aspect_ratio,
duration=duration,
mode=mode,
sound=sound,
)
mv_client = ModelverseClient(api_key)
print(f"Submitting Kling V3 Omni task: model={MODEL_KLING_V3_OMNI}, prompt={prompt!r}")
submit_res = mv_client.submit_task_request(request)
task_id = submit_res.get("output", {}).get("task_id")
if not task_id:
raise Exception(f"Failed to submit task: {submit_res}")
print(f"Kling V3 Omni task submitted: {task_id}")
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")
if task_status == "Failure":
error = status_res.get("output", {}).get("error_message", "Unknown error")
raise Exception(f"Task failed: {error}")
if task_status in ["Pending", "Running"]:
print(f"Task {task_id}: {task_status} ({i + 1}/{max_retries})")
time.sleep(5)
continue
raise Exception(f"Unknown status: {task_status}")
raise Exception("Task timed out")
NODE_CLASS_MAPPINGS = {
"Kling_V3_Omni": KlingV3OmniNode,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"Kling_V3_Omni": "Modelverse Kling V3 Omni",
}
+3
View File
@@ -70,6 +70,9 @@ class ModelverseClient:
}
return self.post(endpoint, payload)
def submit_task_request(self, request: BaseRequest):
return self.post(request.API_PATH, request.build_payload())
def get_task_status(self, task_id):
endpoint = f"/v1/tasks/status"
params = {"task_id": task_id}
@@ -0,0 +1,198 @@
"""
Doubao Seedance 2.0 task submit request builder.
API: https://www.compshare.cn/docs/modelverse/models/video_api/doubao-seedance-2-0
"""
from typing import Any, Dict, List, Optional
from pydantic import Field
from torch import Tensor
from ..utils import BaseRequest, image_to_base64
MODEL = "doubao-seedance-2-0-260128"
RESOLUTIONS = ["480p", "720p", "1080p"]
RATIOS = ["16:9", "4:3", "1:1", "3:4", "9:16", "21:9", "adaptive"]
def _resolve_image(image: Optional[Tensor], url: str, label: str) -> Optional[str]:
has_url = url and url.strip()
has_image = image is not None
if has_url and has_image:
raise ValueError(f"{label}: provide either image or url, not both")
if has_url:
return url.strip()
if has_image:
encoded = image_to_base64(image)
if not encoded:
raise ValueError(f"{label}: failed to convert image to base64")
return encoded
return None
def _normalize_prompt(value) -> str:
if value is None:
return ""
if isinstance(value, list):
value = value[0] if value else ""
if isinstance(value, dict):
return ""
return str(value).strip()
def _image_content(url: str, role: Optional[str] = None) -> Dict[str, Any]:
item: Dict[str, Any] = {
"type": "image_url",
"image_url": {"url": url},
}
if role:
item["role"] = role
return item
class DoubaoSeedance2(BaseRequest):
"""
Doubao Seedance 2.0 video generation task request.
Supports text, first/last frame images, reference image/video/audio.
"""
API_PATH = "/v1/tasks/submit"
prompt: str = Field(default="", description="Text prompt for video generation")
first_frame: Optional[Tensor] = Field(default=None, description="First frame image tensor")
first_frame_url: str = Field(default="", description="First frame image URL or base64")
last_frame: Optional[Tensor] = Field(default=None, description="Last frame image tensor")
last_frame_url: str = Field(default="", description="Last frame image URL or base64")
reference_image: Optional[Tensor] = Field(default=None, description="Reference image tensor")
reference_image_url: str = Field(default="", description="Reference image URL or base64")
reference_video_url: str = Field(default="", description="Reference video URL")
reference_audio_url: str = Field(default="", description="Reference audio URL")
duration: int = Field(default=5, ge=4, le=15, description="Video duration in seconds")
resolution: str = Field(default="720p", description="Output resolution")
ratio: str = Field(default="adaptive", description="Aspect ratio")
seed: int = Field(default=0, ge=0, le=2147483647, description="Random seed")
generate_audio: bool = Field(default=False, description="Whether to generate synchronized audio")
camera_fixed: bool = Field(default=False, description="Whether to fix camera position")
watermark: bool = Field(default=False, description="Whether to add watermark")
draft: bool = Field(default=False, description="Draft mode (480p only)")
def __init__(
self,
prompt: str = "",
first_frame: Optional[Tensor] = None,
first_frame_url: str = "",
last_frame: Optional[Tensor] = None,
last_frame_url: str = "",
reference_image: Optional[Tensor] = None,
reference_image_url: str = "",
reference_video_url: str = "",
reference_audio_url: str = "",
duration: int = 5,
resolution: str = "720p",
ratio: str = "adaptive",
seed: int = 0,
generate_audio: bool = False,
camera_fixed: bool = False,
watermark: bool = False,
draft: bool = False,
**kwargs,
):
super().__init__(**kwargs)
self.prompt = _normalize_prompt(prompt)
self.first_frame = first_frame
self.first_frame_url = first_frame_url
self.last_frame = last_frame
self.last_frame_url = last_frame_url
self.reference_image = reference_image
self.reference_image_url = reference_image_url
self.reference_video_url = reference_video_url
self.reference_audio_url = reference_audio_url
self.duration = duration
self.resolution = resolution
self.ratio = ratio
self.seed = seed
self.generate_audio = generate_audio
self.camera_fixed = camera_fixed
self.watermark = watermark
self.draft = draft
def build_content(self) -> List[Dict[str, Any]]:
if self.draft and self.resolution != "480p":
raise ValueError("Draft mode only supports 480p resolution")
first_url = _resolve_image(self.first_frame, self.first_frame_url, "First frame")
last_url = _resolve_image(self.last_frame, self.last_frame_url, "Last frame")
ref_img_url = _resolve_image(self.reference_image, self.reference_image_url, "Reference image")
content: List[Dict[str, Any]] = []
if self.prompt and self.prompt.strip():
content.append({"type": "text", "text": self.prompt.strip()})
frame_images = [(first_url, "first_frame"), (last_url, "last_frame")]
frame_images = [(url, role) for url, role in frame_images if url]
if ref_img_url:
frame_images.append((ref_img_url, "reference_image"))
for url, role in frame_images:
content.append(_image_content(url, role))
if self.reference_video_url and self.reference_video_url.strip():
content.append({
"type": "video_url",
"video_url": {"url": self.reference_video_url.strip()},
"role": "reference_video",
})
if self.reference_audio_url and self.reference_audio_url.strip():
content.append({
"type": "audio_url",
"audio_url": {"url": self.reference_audio_url.strip()},
"role": "reference_audio",
})
if not content:
raise ValueError(
"At least a text prompt or one input asset (image/video/audio) is required. "
f"Current prompt is empty (received: {self.prompt!r})"
)
return content
def build_parameters(self) -> Dict[str, Any]:
return {
"duration": self.duration,
"resolution": self.resolution,
"ratio": self.ratio,
"seed": self.seed,
"generate_audio": self.generate_audio,
"camera_fixed": self.camera_fixed,
"watermark": self.watermark,
"draft": self.draft,
}
def build_payload(self) -> Dict[str, Any]:
return {
"model": MODEL,
"input": {"content": self.build_content()},
"parameters": self.build_parameters(),
}
def field_required(self):
return []
def field_order(self):
return [
"prompt",
"first_frame",
"last_frame",
"reference_image",
"reference_video_url",
"reference_audio_url",
"duration",
"resolution",
"ratio",
"seed",
"generate_audio",
"camera_fixed",
"watermark",
"draft",
]
@@ -34,19 +34,22 @@ def _tensor_to_base64(image: Tensor, mime_type: str = "image/png") -> Dict[str,
class GeminiFlashImageRequest(BaseRequest):
"""
Request builder for Gemini 2.5 Flash Image generateContent endpoint.
Request builder for Gemini Flash Image generateContent endpoint.
Supports text-to-image and image-edit (text+image to image).
"""
API_PATH = "/v1beta/models/gemini-2.5-flash-image:generateContent"
API_PATH_TEMPLATE = "/v1beta/models/{model}:generateContent"
prompt: str = Field(..., description="Text prompt")
model: str = Field(default="gemini-3.1-flash-image", description="Gemini Flash Image model")
image: Optional[Tensor] = Field(default=None, description="Optional input image for edit")
mime_type: str = Field(default="image/png", description="MIME type for inline image data")
def __init__(self, prompt: str, image: Optional[Tensor] = None, mime_type: str = "image/png", **kwargs):
def __init__(self, prompt: str, model: str = "gemini-3.1-flash-image", image: Optional[Tensor] = None, mime_type: str = "image/png", **kwargs):
super().__init__(**kwargs)
self.prompt = prompt
self.model = model
self.API_PATH = self.API_PATH_TEMPLATE.format(model=model)
self.image = image
self.mime_type = mime_type
@@ -77,5 +80,4 @@ class GeminiFlashImageRequest(BaseRequest):
return ["prompt"]
def field_order(self):
return ["prompt", "image", "mime_type"]
return ["model", "prompt", "image", "mime_type"]
@@ -1,5 +1,5 @@
"""
Gemini 3 Pro Image Preview (Nano Banana Pro) request builder.
Gemini 3 Pro Image (Nano Banana Pro) request builder.
Supports text-to-image and image editing with advanced features like
aspect ratio, resolution control, and Google Search grounding.
"""
@@ -40,12 +40,12 @@ def _tensor_to_base64(image: Tensor, mime_type: str = "image/png") -> Dict[str,
class GeminiProImageRequest(BaseRequest):
"""
Request builder for Gemini 3 Pro Image Preview (Nano Banana Pro) generateContent endpoint.
Request builder for Gemini 3 Pro Image (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"
API_PATH = "/v1beta/models/gemini-3-pro-image:generateContent"
prompt: str = Field(..., description="Text prompt")
images: Optional[List[Tensor]] = Field(default=None, description="Optional input images (up to 14)")
@@ -0,0 +1,52 @@
"""Shared helpers for Kling V3 request builders."""
import base64
from typing import Optional
from torch import Tensor
from ..utils import encode_image, tensor2images
MODEL_KLING_V3 = "kling-v3"
MODEL_KLING_V3_OMNI = "kling-v3-omni"
MODES = ["std", "pro"]
SOUNDS = ["off", "on"]
ASPECT_RATIOS = ["16:9", "9:16", "1:1"]
KLING_V3_TYPES = ["auto", "t2v", "i2v", "motion_control"]
CHARACTER_ORIENTATIONS = ["image", "video"]
YES_NO = ["yes", "no"]
REFER_TYPES = ["feature", "base"]
def normalize_kling_image_value(value: str) -> str:
"""Kling accepts http(s) URLs or raw base64 without a data:image prefix."""
value = value.strip()
if value.startswith("data:image/") and "," in value:
return value.split(",", 1)[1]
return value
def resolve_image(image: Optional[Tensor], url: str, label: str) -> Optional[str]:
has_url = url and url.strip()
has_image = image is not None
if has_url and has_image:
raise ValueError(f"{label}: provide either image or url, not both")
if has_url:
return normalize_kling_image_value(url)
if has_image:
data_bytes, _ = encode_image(tensor2images(image)[0])
if not data_bytes:
raise ValueError(f"{label}: failed to convert image to base64")
return base64.b64encode(data_bytes).decode("utf-8")
return None
def normalize_prompt(value) -> str:
if value is None:
return ""
if isinstance(value, list):
value = value[0] if value else ""
if isinstance(value, dict):
return ""
return str(value).strip()
+172
View File
@@ -0,0 +1,172 @@
"""
Kling V3 task submit request builder.
Routes to t2v / i2v / motion_control via input fields or parameters.kling_v3_type.
"""
from typing import Any, Dict, Optional
from pydantic import Field
from torch import Tensor
from ..utils import BaseRequest
from .kling_common import (
ASPECT_RATIOS,
CHARACTER_ORIENTATIONS,
KLING_V3_TYPES,
MODEL_KLING_V3,
MODES,
SOUNDS,
YES_NO,
normalize_prompt,
resolve_image,
)
class KlingV3(BaseRequest):
"""
Kling V3 unified video generation task request.
Auto-routes to text-to-video, image-to-video, or motion control.
"""
API_PATH = "/v1/tasks/submit"
prompt: str = Field(default="", description="Text prompt for video generation")
negative_prompt: str = Field(default="", description="Negative prompt")
kling_v3_type: str = Field(
default="auto",
description="Task variant: auto, t2v, i2v, motion_control",
)
first_frame: Optional[Tensor] = Field(default=None, description="First frame image tensor")
first_frame_url: str = Field(default="", description="First frame image URL or base64")
last_frame: Optional[Tensor] = Field(default=None, description="Last frame image tensor")
last_frame_url: str = Field(default="", description="Last frame image URL or base64")
reference_video_url: str = Field(default="", description="Reference video URL for motion control")
aspect_ratio: str = Field(default="16:9", description="Aspect ratio")
duration: int = Field(default=5, ge=3, le=15, description="Video duration in seconds")
mode: str = Field(default="std", description="Generation mode: std or pro")
sound: str = Field(default="off", description="Generate synchronized audio: on or off")
shot_type: str = Field(default="", description="Shot type, e.g. multi for multi-shot")
character_orientation: str = Field(
default="image",
description="Motion control orientation: image or video",
)
keep_original_sound: str = Field(
default="no",
description="Keep original sound from reference video: yes or no",
)
def __init__(
self,
prompt: str = "",
negative_prompt: str = "",
kling_v3_type: str = "auto",
first_frame: Optional[Tensor] = None,
first_frame_url: str = "",
last_frame: Optional[Tensor] = None,
last_frame_url: str = "",
reference_video_url: str = "",
aspect_ratio: str = "16:9",
duration: int = 5,
mode: str = "std",
sound: str = "off",
shot_type: str = "",
character_orientation: str = "image",
keep_original_sound: str = "no",
**kwargs,
):
super().__init__(**kwargs)
self.prompt = normalize_prompt(prompt)
self.negative_prompt = normalize_prompt(negative_prompt)
self.kling_v3_type = kling_v3_type
self.first_frame = first_frame
self.first_frame_url = first_frame_url
self.last_frame = last_frame
self.last_frame_url = last_frame_url
self.reference_video_url = reference_video_url
self.aspect_ratio = aspect_ratio
self.duration = duration
self.mode = mode
self.sound = sound
self.shot_type = shot_type
self.character_orientation = character_orientation
self.keep_original_sound = keep_original_sound
def _is_motion_control(self) -> bool:
if self.kling_v3_type == "motion_control":
return True
if self.kling_v3_type == "auto" and self.reference_video_url and self.reference_video_url.strip():
return True
return False
def build_input(self) -> Dict[str, Any]:
task_input: Dict[str, Any] = {}
if self.prompt:
task_input["prompt"] = self.prompt
if self.negative_prompt:
task_input["negative_prompt"] = self.negative_prompt
first_url = resolve_image(self.first_frame, self.first_frame_url, "First frame")
last_url = resolve_image(self.last_frame, self.last_frame_url, "Last frame")
if self._is_motion_control():
if not first_url:
raise ValueError("Motion control requires a first frame image or URL")
if not self.reference_video_url or not self.reference_video_url.strip():
raise ValueError("Motion control requires reference_video_url")
task_input["first_frame_url"] = first_url
task_input["video_url"] = self.reference_video_url.strip()
return task_input
if first_url:
task_input["first_frame_url"] = first_url
if last_url:
task_input["last_frame_url"] = last_url
if not task_input.get("prompt") and not first_url:
raise ValueError(
"At least a prompt or first frame image is required. "
f"Current prompt is empty (received: {self.prompt!r})"
)
return task_input
def build_parameters(self) -> Dict[str, Any]:
params: Dict[str, Any] = {
"duration": self.duration,
"aspect_ratio": self.aspect_ratio,
"mode": self.mode,
"sound": self.sound,
}
if self.kling_v3_type and self.kling_v3_type != "auto":
params["kling_v3_type"] = self.kling_v3_type
if self.shot_type:
params["shot_type"] = self.shot_type
if self._is_motion_control():
params["character_orientation"] = self.character_orientation
params["keep_original_sound"] = self.keep_original_sound
return params
def build_payload(self) -> Dict[str, Any]:
return {
"model": MODEL_KLING_V3,
"input": self.build_input(),
"parameters": self.build_parameters(),
}
def field_required(self):
return []
def field_order(self):
return [
"prompt",
"negative_prompt",
"kling_v3_type",
"first_frame",
"last_frame",
"reference_video_url",
"aspect_ratio",
"duration",
"mode",
"sound",
"shot_type",
"character_orientation",
"keep_original_sound",
]
+155
View File
@@ -0,0 +1,155 @@
"""
Kling V3 Omni task submit request builder.
Uses image_list / video_list / element_list parameters.
"""
from typing import Any, Dict, List, Optional
from pydantic import Field
from torch import Tensor
from ..utils import BaseRequest
from .kling_common import (
ASPECT_RATIOS,
MODEL_KLING_V3_OMNI,
MODES,
REFER_TYPES,
SOUNDS,
YES_NO,
normalize_prompt,
resolve_image,
)
class KlingV3Omni(BaseRequest):
"""
Kling V3 Omni multimodal video generation task request.
Supports reference images, videos, and element library IDs.
"""
API_PATH = "/v1/tasks/submit"
prompt: str = Field(default="", description="Text prompt for video generation")
negative_prompt: str = Field(default="", description="Negative prompt")
first_frame: Optional[Tensor] = Field(default=None, description="First frame image tensor")
first_frame_url: str = Field(default="", description="First frame image URL or base64")
last_frame: Optional[Tensor] = Field(default=None, description="Last frame image tensor")
last_frame_url: str = Field(default="", description="Last frame image URL or base64")
reference_video_url: str = Field(default="", description="Reference video URL")
refer_type: str = Field(default="feature", description="Video reference type: feature or base")
keep_original_sound: str = Field(default="no", description="Keep original video sound: yes or no")
element_id: int = Field(default=0, description="Element library ID (0 to skip)")
aspect_ratio: str = Field(default="16:9", description="Aspect ratio")
duration: int = Field(default=5, ge=3, le=15, description="Video duration in seconds")
mode: str = Field(default="std", description="Generation mode: std or pro")
sound: str = Field(default="off", description="Generate synchronized audio: on or off")
def __init__(
self,
prompt: str = "",
negative_prompt: str = "",
first_frame: Optional[Tensor] = None,
first_frame_url: str = "",
last_frame: Optional[Tensor] = None,
last_frame_url: str = "",
reference_video_url: str = "",
refer_type: str = "feature",
keep_original_sound: str = "no",
element_id: int = 0,
aspect_ratio: str = "16:9",
duration: int = 5,
mode: str = "std",
sound: str = "off",
**kwargs,
):
super().__init__(**kwargs)
self.prompt = normalize_prompt(prompt)
self.negative_prompt = normalize_prompt(negative_prompt)
self.first_frame = first_frame
self.first_frame_url = first_frame_url
self.last_frame = last_frame
self.last_frame_url = last_frame_url
self.reference_video_url = reference_video_url
self.refer_type = refer_type
self.keep_original_sound = keep_original_sound
self.element_id = element_id
self.aspect_ratio = aspect_ratio
self.duration = duration
self.mode = mode
self.sound = sound
def build_input(self) -> Dict[str, Any]:
task_input: Dict[str, Any] = {}
if self.prompt:
task_input["prompt"] = self.prompt
if self.negative_prompt:
task_input["negative_prompt"] = self.negative_prompt
if not task_input.get("prompt"):
raise ValueError(
"Prompt is required for Kling V3 Omni. "
f"Current prompt is empty (received: {self.prompt!r})"
)
return task_input
def build_image_list(self) -> List[Dict[str, str]]:
image_list: List[Dict[str, str]] = []
first_url = resolve_image(self.first_frame, self.first_frame_url, "First frame")
last_url = resolve_image(self.last_frame, self.last_frame_url, "Last frame")
if first_url:
image_list.append({"image_url": first_url, "type": "first_frame"})
if last_url:
image_list.append({"image_url": last_url, "type": "end_frame"})
return image_list
def build_parameters(self) -> Dict[str, Any]:
has_video = bool(self.reference_video_url and self.reference_video_url.strip())
if has_video and self.sound == "on":
raise ValueError("sound must be 'off' when reference_video_url is provided")
params: Dict[str, Any] = {
"duration": self.duration,
"aspect_ratio": self.aspect_ratio,
"mode": self.mode,
"sound": self.sound,
}
image_list = self.build_image_list()
if image_list:
params["image_list"] = image_list
if has_video:
params["video_list"] = [{
"video_url": self.reference_video_url.strip(),
"refer_type": self.refer_type,
"keep_original_sound": self.keep_original_sound,
}]
if self.element_id:
params["element_list"] = [{"element_id": self.element_id}]
return params
def build_payload(self) -> Dict[str, Any]:
return {
"model": MODEL_KLING_V3_OMNI,
"input": self.build_input(),
"parameters": self.build_parameters(),
}
def field_required(self):
return ["prompt"]
def field_order(self):
return [
"prompt",
"negative_prompt",
"first_frame",
"last_frame",
"reference_video_url",
"refer_type",
"keep_original_sound",
"element_id",
"aspect_ratio",
"duration",
"mode",
"sound",
]
+121 -8
View File
@@ -1,5 +1,9 @@
import os
import json
import configparser
import torch
import server
from aiohttp import web
from comfy.comfy_types.node_typing import IO
try:
@@ -19,6 +23,61 @@ except Exception as e:
config = None
secrets_path = os.path.join(parent_dir, 'secrets.json')
def load_secrets():
if not os.path.exists(secrets_path):
with open(secrets_path, 'w') as secrets_file:
json.dump({}, secrets_file, indent=2)
with open(secrets_path) as secrets_file:
return json.load(secrets_file)
def save_secrets(secrets):
with open(secrets_path, 'w') as secrets_file:
json.dump(secrets, secrets_file, indent=2)
async def get_modelverse_secrets(_request):
return web.json_response(load_secrets())
async def set_modelverse_secret(request):
try:
data = await request.json()
except Exception:
return web.json_response({"error": "Invalid or empty request body"}, status=400)
key = data.get("key", "").strip()
value = data.get("value", "")
if not key:
return web.json_response({"error": "Key cannot be empty"}, status=400)
secrets = load_secrets()
secrets[key] = value
save_secrets(secrets)
return web.json_response({"ok": True})
async def delete_modelverse_secret(request):
key = request.match_info["key"]
secrets = load_secrets()
if key not in secrets:
return web.json_response({"error": "Key not found"}, status=404)
del secrets[key]
save_secrets(secrets)
return web.json_response({"ok": True})
if not getattr(server.PromptServer.instance, "_modelverse_secrets_registered", False):
server.PromptServer.instance.routes.get("/modelverse-secrets")(get_modelverse_secrets)
server.PromptServer.instance.routes.post("/modelverse-secrets")(set_modelverse_secret)
server.PromptServer.instance.routes.delete("/modelverse-secrets/{key}")(delete_modelverse_secret)
server.PromptServer.instance._modelverse_secrets_registered = True
class ModelverseAPIClient:
"""
Ucloud Modelverse API Client Node
@@ -33,7 +92,11 @@ class ModelverseAPIClient:
def INPUT_TYPES(s):
return {
"required": {
"api_key": ("STRING", {"multiline": False, "default": ""})
"api_key": ("STRING", {
"multiline": False,
"default": "",
"tooltip": "ModelVerse API key. Leave empty to read MODELVERSE_API_KEY from config.ini and avoid storing the key in workflow files."
})
},
}
@@ -72,18 +135,64 @@ class ModelverseAPIClient:
},)
class ModelverseSecretClient:
"""
UCloud Modelverse API Client from a locally managed secret.
This node stores only the secret name in the workflow. The actual API key is
read from secrets.json at execution time.
"""
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"secret": ("STRING", {
"multiline": False,
"default": "",
"tooltip": "Secret name from Modelverse Secrets Manager. The workflow stores this name, not the API key."
})
},
}
RETURN_TYPES = ("MODELVERSE_API_CLIENT",)
RETURN_NAMES = ("client",)
FUNCTION = "create_client"
CATEGORY = "UCLOUD_MODELVERSE"
@classmethod
def IS_CHANGED(cls, secret):
return load_secrets().get(secret, "")
def create_client(self, secret):
secret = secret.strip() if isinstance(secret, str) else secret
if not secret:
raise ValueError("Secret name is required")
secrets = load_secrets()
api_key = secrets.get(secret)
if not api_key:
raise ValueError(f"Secret '{secret}' not found in secrets.json")
return ({
"api_key": api_key
},)
class ModelverseImagePacker:
"""
Ucloud Modelverse Image Packer
This node packs multiple images into a list for multi-image editing.
This node packs multiple images into a batched IMAGE tensor for multi-image editing.
Args:
images1: The first image/image_list to be packed together with.
images2: The second image/image_list to be packed together with. et cetera.
images1: The first image to be packed together with.
images2: The second image to be packed together with, et cetera.
Returns:
image_list: the pack of all input images.
images: batched IMAGE tensor for Flux Kontext Pro/Max multi-image mode.
"""
@classmethod
@@ -100,8 +209,8 @@ class ModelverseImagePacker:
}
}
RETURN_TYPES = ("IMAGE_LIST",)
RETURN_NAMES = ("image_list",)
RETURN_TYPES = (IO.IMAGE,)
RETURN_NAMES = ("images",)
FUNCTION = "pack_images"
@@ -122,14 +231,18 @@ class ModelverseImagePacker:
for i in (images1, images2, images3, images4, images5):
if i is not None:
result.extend(to_list(i))
return (result,)
if not result:
raise ValueError("At least one image is required")
return (torch.cat(result, dim=0),)
NODE_CLASS_MAPPINGS = {
'UCloud ModelVerse Client': ModelverseAPIClient,
'UCloud ModelVerse Secret Client': ModelverseSecretClient,
'ModelVerse Image Packer': ModelverseImagePacker
}
NODE_DISPLAY_NAME_MAPPINGS = {
'UCloud ModelVerse Client': 'Modelverse Client',
'UCloud ModelVerse Secret Client': 'Modelverse Secret Client',
'ModelVerse Image Packer': 'Modelverse Image Packer'
}
+18 -15
View File
@@ -14,18 +14,20 @@ import folder_paths
# Hardcoded models list
DEFAULT_MODELS = [
'ByteDance/doubao-1.5-thinking-vision-pro',
'ByteDance/doubao-seed-1.6',
'ByteDance/doubao-seed-1.6-thinking',
'Qwen/QwQ-32B',
'Qwen/Qwen-Image',
'Qwen/Qwen-Image-Edit',
'Qwen/Qwen3-235B-A22B',
'Qwen/Qwen3-30B-A3B',
'Qwen/Qwen3-32B',
'Qwen/Qwen3-Coder',
'Wan-AI/Wan2.2-I2V',
'Wan-AI/Wan2.2-T2V',
'doubao-seedance-2-0-260128',
'kling-v3',
'kling-v3-omni',
'happyhorse-1.0-t2v',
'happyhorse-1.0-i2v',
'happyhorse-1.0-r2v',
'veo-3.1-generate-001',
'veo-3.1-fast-generate-001',
'baidu/ernie-4.5-turbo-128k',
'baidu/ernie-4.5-turbo-vl-32k',
'baidu/ernie-x1-turbo-32k',
@@ -36,33 +38,34 @@ DEFAULT_MODELS = [
'black-forest-labs/flux-kontext-pro/multi',
'black-forest-labs/flux-kontext-pro/text-to-image',
'black-forest-labs/flux.1-dev',
'claude-4-opus',
'claude-4-sonnet',
'deepseek-ai/DeepSeek-Prover-V2-671B',
'deepseek-ai/DeepSeek-R1',
'deepseek-ai/DeepSeek-R1-0528',
'deepseek-ai/DeepSeek-R1-Distill-Llama-70B',
'deepseek-ai/DeepSeek-V3-0324',
'deepseek-ai/DeepSeek-V3.1',
'gemini-2.5-flash',
'gemini-2.5-flash-image',
'gemini-2.5-pro',
'gemini-3.1-flash-image',
'gemini-3-pro-image',
'gpt-4.1-mini',
'grok-4',
'moonshotai/Kimi-K2-Instruct',
'openai/gpt-4.1',
'openai/gpt-5',
'openai/gpt-5-mini',
'openai/gpt-oss-120b',
'openai/gpt-oss-20b',
'qwen/qwen2.5-vl-72b-instruct',
'zai-org/glm-4.5',
'zai-org/glm-4.5v',
'viduq2',
'viduq2-pro',
'viduq2-turbo',
'viduq2-pro-fast',
'viduq3-pro',
'viduq3-turbo',
'zai-org/glm-5',
'stepfun-ai/step1x-edit'
]
# Default selected model
DEFAULT_MODEL = "zai-org/glm-4.5"
DEFAULT_MODEL = "zai-org/glm-5"
class ModelverseChat:
+1 -1
View File
@@ -34,7 +34,7 @@ class QwenImageEditNode:
RETURN_TYPES = (IO.IMAGE,)
RETURN_NAMES = ("image",)
CATEGORY = "UCLOUD_MODELVERSE"
CATEGORY = "UCLOUD_MODELVERSE/Qwen-Image"
FUNCTION = "execute"
async def execute(
+1 -1
View File
@@ -34,7 +34,7 @@ class QwenImageT2INode:
RETURN_TYPES = (IO.IMAGE,)
RETURN_NAMES = ("image",)
CATEGORY = "UCLOUD_MODELVERSE"
CATEGORY = "UCLOUD_MODELVERSE/Qwen-Image"
FUNCTION = "execute"
async def execute(
+211
View File
@@ -0,0 +1,211 @@
"""
Google Veo 3.1 video generation
Models: veo-3.1-generate-001, veo-3.1-fast-generate-001
"""
import base64
import time
from .modelverse_api.client import ModelverseClient
from .modelverse_api.utils import decode_image, encode_image, fetch_image, tensor2images
from comfy.comfy_types.node_typing import IO
MODELS = ["veo-3.1-generate-001", "veo-3.1-fast-generate-001"]
ASPECT_RATIOS = ["16:9", "9:16"]
RESOLUTIONS = ["720p", "1080p"]
DURATIONS = [4, 6, 8]
PERSON_GENERATIONS = ["dont_allow", "allow_adult"]
def _bytes_to_veo_image(data_bytes, fmt):
mime = "image/png" if fmt == "PNG" else "image/jpeg"
return {
"bytesBase64Encoded": base64.b64encode(data_bytes).decode("utf-8"),
"mimeType": mime,
}
def _tensor_to_veo_image(tensor):
data_bytes, fmt = encode_image(tensor2images(tensor)[0])
return _bytes_to_veo_image(data_bytes, fmt)
def _url_to_veo_image(url, label):
url = url.strip()
if url.startswith("data:image/") and "," in url:
header, data = url.split(",", 1)
mime = header.split(";")[0].replace("data:", "")
return {"bytesBase64Encoded": data, "mimeType": mime}
if url.startswith(("http://", "https://")):
image_data = fetch_image(url)
img = decode_image(image_data)
data_bytes, fmt = encode_image(img)
return _bytes_to_veo_image(data_bytes, fmt)
raise ValueError(f"{label}: URL must be http(s) or a data:image/...;base64,... value")
def _resolve_veo_image(image, url, label):
has_url = url and url.strip()
has_image = image is not None
if has_url and has_image:
raise ValueError(f"{label}: provide either image or url, not both")
if has_url:
return _url_to_veo_image(url, label)
if has_image:
return _tensor_to_veo_image(image)
return None
class Veo31VideoNode:
"""
Veo 3.1 video generation.
Supports text-to-video, image-to-video, and first/last-frame video.
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"client": ("MODELVERSE_API_CLIENT",),
"model": (MODELS, {
"default": "veo-3.1-generate-001",
"tooltip": "veo-3.1-generate-001: standard quality, veo-3.1-fast-generate-001: faster generation",
}),
"prompt": (IO.STRING, {
"multiline": True,
"default": "",
"tooltip": "Text prompt describing the desired video",
}),
"generate_audio": (IO.BOOLEAN, {
"default": True,
"tooltip": "Whether to generate synchronized audio (required by Veo API)",
}),
},
"optional": {
"first_frame_image": (IO.IMAGE, {"tooltip": "First frame image for image-to-video"}),
"first_frame_url": (IO.STRING, {
"default": "",
"tooltip": "First frame image URL (use either this OR first_frame_image)",
}),
"last_frame_image": (IO.IMAGE, {"tooltip": "Last frame image for start-end video"}),
"last_frame_url": (IO.STRING, {
"default": "",
"tooltip": "Last frame image URL (use either this OR last_frame_image)",
}),
"negative_prompt": (IO.STRING, {
"multiline": True,
"default": "",
"tooltip": "Negative prompt for unwanted elements",
}),
"aspect_ratio": (ASPECT_RATIOS, {
"default": "16:9",
"tooltip": "Output aspect ratio",
}),
"resolution": (RESOLUTIONS, {
"default": "720p",
"tooltip": "Output resolution",
}),
"duration": (DURATIONS, {
"default": 8,
"tooltip": "Video duration in seconds: 4, 6, or 8",
}),
"seed": (IO.INT, {
"default": 0, "min": 0, "max": 4294967295,
"tooltip": "Random seed (0 to skip)",
}),
"person_generation": (PERSON_GENERATIONS, {
"default": "allow_adult",
"tooltip": "Safety setting for person/face generation",
}),
},
}
RETURN_TYPES = (IO.STRING, IO.STRING)
RETURN_NAMES = ("url", "task_id")
FUNCTION = "generate"
CATEGORY = "UCLOUD_MODELVERSE/Veo"
def generate(
self,
client,
model,
prompt,
generate_audio=True,
first_frame_image=None,
first_frame_url="",
last_frame_image=None,
last_frame_url="",
negative_prompt="",
aspect_ratio="16:9",
resolution="720p",
duration=8,
seed=0,
person_generation="allow_adult",
):
api_key = client.get("api_key")
if not api_key:
raise ValueError("API key is not set")
if not prompt or not prompt.strip():
raise ValueError("prompt is required for Veo 3.1")
first_image = _resolve_veo_image(first_frame_image, first_frame_url, "First frame")
last_image = _resolve_veo_image(last_frame_image, last_frame_url, "Last frame")
if last_image and not first_image:
raise ValueError("First frame is required when last frame is provided")
mv_client = ModelverseClient(api_key)
task_input = {"prompt": prompt.strip()}
if negative_prompt and negative_prompt.strip():
task_input["negative_prompt"] = negative_prompt.strip()
if first_image:
task_input["image"] = first_image
if last_image:
task_input["last_frame"] = last_image
parameters = {
"generate_audio": generate_audio,
"aspect_ratio": aspect_ratio,
"resolution": resolution,
"duration": duration,
"person_generation": person_generation,
}
if seed > 0:
parameters["seed"] = seed
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"Veo 3.1 task submitted: model={model}, task_id={task_id}")
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")
if task_status == "Failure":
error = status_res.get("output", {}).get("error_message", "Unknown error")
raise Exception(f"Task failed: {error}")
if task_status in ["Pending", "Running"]:
print(f"Task {task_id}: {task_status} ({i + 1}/{max_retries})")
time.sleep(5)
continue
raise Exception(f"Unknown status: {task_status}")
raise Exception("Task timed out")
NODE_CLASS_MAPPINGS = {
"Veo_3_1_Video": Veo31VideoNode,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"Veo_3_1_Video": "Modelverse Veo 3.1 Video",
}
+7 -5
View File
@@ -1,6 +1,6 @@
"""
Vidu Img2Video - 图生视频模型
Models: viduq2-pro, viduq2-turbo
Models: viduq3-pro, viduq3-turbo, viduq2-pro, viduq2-turbo, viduq2-pro-fast
"""
import time
from .modelverse_api.client import ModelverseClient
@@ -8,7 +8,7 @@ from .modelverse_api.utils import image_to_base64
from comfy.comfy_types.node_typing import IO
MODELS = ["viduq2-pro", "viduq2-turbo"]
MODELS = ["viduq3-pro", "viduq3-turbo", "viduq2-pro", "viduq2-turbo", "viduq2-pro-fast"]
RESOLUTIONS = ["540p", "720p", "1080p"]
MOVEMENT_AMPLITUDES = ["auto", "small", "medium", "large"]
@@ -16,7 +16,7 @@ MOVEMENT_AMPLITUDES = ["auto", "small", "medium", "large"]
class ViduImg2VideoNode:
"""
Vidu Img2Video - 图生视频
Models: viduq2-pro (效果好,细节丰富), viduq2-turbo (效果好,生成快)
Models: viduq3-pro/viduq3-turbo (1-16s), viduq2-pro/viduq2-turbo/viduq2-pro-fast (1-10s)
"""
@classmethod
@@ -24,8 +24,8 @@ class ViduImg2VideoNode:
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": "视频时长(秒)"}),
"model": (MODELS, {"default": "viduq3-pro", "tooltip": "viduq3-pro/turbo: 支持1-16秒; viduq2-pro/turbo/pro-fast: 支持1-10秒"}),
"duration": (IO.INT, {"default": 5, "min": 1, "max": 16, "step": 1, "tooltip": "视频时长(秒),viduq3系列支持1-16秒,viduq2系列支持1-10秒"}),
"resolution": (RESOLUTIONS, {"default": "720p", "tooltip": "分辨率"}),
"movement_amplitude": (MOVEMENT_AMPLITUDES, {"default": "auto", "tooltip": "运动幅度"}),
},
@@ -59,6 +59,8 @@ class ViduImg2VideoNode:
raise ValueError("请提供 first_frame_image 或 first_frame_url,不能同时提供")
if not has_url and not has_image:
raise ValueError("必须提供 first_frame_image 或 first_frame_url")
if duration > 10 and not model.startswith("viduq3-"):
raise ValueError("只有 viduq3 系列模型支持超过10秒的视频时长")
task_input = {}
if has_url:
+6 -4
View File
@@ -1,6 +1,6 @@
"""
Vidu Reference2Video - 参考生视频模型
Model: viduq2
Models: viduq3-turbo, viduq2
支持1-7张参考图片,生成具备主体一致的视频
"""
import time
@@ -9,6 +9,7 @@ from .modelverse_api.utils import image_to_base64
from comfy.comfy_types.node_typing import IO
MODELS = ["viduq3-turbo", "viduq2"]
ASPECT_RATIOS = ["16:9", "9:16", "3:4", "4:3", "1:1"]
RESOLUTIONS = ["540p", "720p", "1080p"]
@@ -16,7 +17,7 @@ RESOLUTIONS = ["540p", "720p", "1080p"]
class ViduReference2VideoNode:
"""
Vidu Reference2Video - 参考生视频
Model: viduq2
Models: viduq3-turbo, viduq2
支持1-7张参考图片,生成具备主体一致的视频
"""
@@ -25,6 +26,7 @@ class ViduReference2VideoNode:
return {
"required": {
"client": ("MODELVERSE_API_CLIENT",),
"model": (MODELS, {"default": "viduq3-turbo", "tooltip": "viduq3-turbo: 生成快, viduq2: 旧版模型"}),
"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": "长宽比"}),
@@ -49,7 +51,7 @@ class ViduReference2VideoNode:
FUNCTION = "generate"
CATEGORY = "UCLOUD_MODELVERSE/Vidu"
def generate(self, client, prompt, duration, aspect_ratio, resolution,
def generate(self, client, model, 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):
@@ -93,7 +95,7 @@ class ViduReference2VideoNode:
}
# Submit task
submit_res = mv_client.submit_task("viduq2", task_input, parameters)
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}")
+7 -5
View File
@@ -1,6 +1,6 @@
"""
Vidu StartEnd2Video - 首尾帧生视频模型
Models: viduq2-pro, viduq2-turbo
Models: viduq3-pro, viduq3-turbo, viduq2-pro-fast, viduq2-pro, viduq2-turbo
"""
import time
from .modelverse_api.client import ModelverseClient
@@ -8,7 +8,7 @@ from .modelverse_api.utils import image_to_base64
from comfy.comfy_types.node_typing import IO
MODELS = ["viduq2-pro", "viduq2-turbo"]
MODELS = ["viduq3-pro", "viduq3-turbo", "viduq2-pro-fast", "viduq2-pro", "viduq2-turbo"]
RESOLUTIONS = ["540p", "720p", "1080p"]
MOVEMENT_AMPLITUDES = ["auto", "small", "medium", "large"]
@@ -16,7 +16,7 @@ MOVEMENT_AMPLITUDES = ["auto", "small", "medium", "large"]
class ViduStartEnd2VideoNode:
"""
Vidu StartEnd2Video - 首尾帧生视频
Models: viduq2-pro (效果好,细节丰富), viduq2-turbo (效果好,生成快)
Models: viduq3-pro/viduq3-turbo (1-16s), viduq2-pro-fast/viduq2-pro/viduq2-turbo (1-8s)
注意:首尾帧分辨率需相近 (0.8~1.25)
"""
@@ -25,8 +25,8 @@ class ViduStartEnd2VideoNode:
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"}),
"model": (MODELS, {"default": "viduq3-pro", "tooltip": "viduq3-pro/turbo: 支持1-16秒; viduq2-pro-fast/pro/turbo: 支持1-8秒"}),
"duration": (IO.INT, {"default": 5, "min": 1, "max": 16, "step": 1, "tooltip": "视频时长(秒),viduq3系列支持1-16秒,viduq2系列支持1-8秒"}),
"resolution": (RESOLUTIONS, {"default": "720p", "tooltip": "分辨率"}),
"movement_amplitude": (MOVEMENT_AMPLITUDES, {"default": "auto", "tooltip": "运动幅度"}),
},
@@ -71,6 +71,8 @@ class ViduStartEnd2VideoNode:
raise ValueError("尾帧:请提供 image 或 url,不能同时提供")
if not has_last_url and not has_last_image:
raise ValueError("必须提供尾帧图片")
if duration > 8 and not model.startswith("viduq3-"):
raise ValueError("只有 viduq3 系列模型支持超过8秒的视频时长")
task_input = {}
+6 -4
View File
@@ -1,12 +1,13 @@
"""
Vidu Text2Video - 文生视频模型
Model: viduq2
Models: viduq3-pro, viduq3-turbo, viduq2
"""
import time
from .modelverse_api.client import ModelverseClient
from comfy.comfy_types.node_typing import IO
MODELS = ["viduq3-pro", "viduq3-turbo", "viduq2"]
ASPECT_RATIOS = ["16:9", "9:16", "3:4", "4:3", "1:1"]
RESOLUTIONS = ["540p", "720p", "1080p"]
@@ -14,7 +15,7 @@ RESOLUTIONS = ["540p", "720p", "1080p"]
class ViduText2VideoNode:
"""
Vidu Text2Video - 文生视频
Model: viduq2
Models: viduq3-pro, viduq3-turbo, viduq2
"""
@classmethod
@@ -22,6 +23,7 @@ class ViduText2VideoNode:
return {
"required": {
"client": ("MODELVERSE_API_CLIENT",),
"model": (MODELS, {"default": "viduq3-pro", "tooltip": "viduq3-pro: 效果好细节丰富, viduq3-turbo: 生成快, viduq2: 旧版模型"}),
"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": "长宽比"}),
@@ -39,7 +41,7 @@ class ViduText2VideoNode:
FUNCTION = "generate"
CATEGORY = "UCLOUD_MODELVERSE/Vidu"
def generate(self, client, prompt, duration, aspect_ratio, resolution, seed=0, guidance_scale=7.5, bgm=False):
def generate(self, client, model, 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")
@@ -58,7 +60,7 @@ class ViduText2VideoNode:
}
# Submit task
submit_res = mv_client.submit_task("viduq2", task_input, parameters)
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}")
+1 -1
View File
@@ -29,7 +29,7 @@ class Modelverse_WanAII2V:
RETURN_TYPES = (IO.STRING, IO.STRING)
RETURN_NAMES = ("url", "task_id")
FUNCTION = "generate_video"
CATEGORY = "UCLOUD_MODELVERSE"
CATEGORY = "UCLOUD_MODELVERSE/Wan"
def generate_video(self, client, prompt, first_frame_image=None, first_frame_url="", last_frame_image=None, last_frame_url="", negative_prompt="", resolution="720P", seed=0):
api_key = client.get("api_key")
+1 -1
View File
@@ -23,7 +23,7 @@ class Modelverse_WanAIT2V:
RETURN_TYPES = (IO.STRING, IO.STRING)
RETURN_NAMES = ("url", "task_id")
FUNCTION = "generate_video"
CATEGORY = "UCLOUD_MODELVERSE"
CATEGORY = "UCLOUD_MODELVERSE/Wan"
def generate_video(self, client, prompt, negative_prompt, resolution, size, seed):
api_key = client.get("api_key")
+2 -2
View File
@@ -1,9 +1,9 @@
[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.8"
version = "1.0.9"
license = {file = "LICENSE"}
dependencies = ["requests", "pillow"]
dependencies = ["requests", "pillow", "numpy", "openai"]
[project.urls]
Repository = "https://github.com/ucloud/comfyui-plugin"
+1
View File
@@ -1,4 +1,5 @@
requests >= 2.32.4
pillow >= 11
numpy >= 2.0
openai >= 1.107.0
GitPython==3.1.45
+136
View File
@@ -0,0 +1,136 @@
.mv-secret-overlay {
position: fixed;
inset: 0;
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.58);
}
.mv-secret-dialog {
width: min(760px, 92vw);
max-height: 82vh;
padding: 18px;
display: flex;
flex-direction: column;
gap: 12px;
color: #f0f0f0;
background: #171717;
border: 1px solid #3f3f3f;
border-radius: 8px;
box-shadow: 0 18px 60px rgba(0, 0, 0, 0.45);
font-family: sans-serif;
}
.mv-secret-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.mv-secret-title {
margin: 0;
font-size: 16px;
font-weight: 600;
}
.mv-secret-close {
border: 0;
color: #bdbdbd;
background: transparent;
font-size: 18px;
line-height: 1;
cursor: pointer;
}
.mv-secret-rows {
display: flex;
flex-direction: column;
gap: 8px;
overflow-y: auto;
}
.mv-secret-row {
display: grid;
grid-template-columns: minmax(130px, 0.7fr) minmax(180px, 1.3fr) auto auto;
gap: 8px;
align-items: center;
}
.mv-secret-input {
min-width: 0;
height: 30px;
padding: 5px 8px;
border: 1px solid #555;
border-radius: 4px;
color: #eee;
background: #262626;
font-size: 13px;
outline: none;
}
.mv-secret-input:focus {
border-color: #7d8cff;
}
.mv-secret-button,
.mv-secret-add {
height: 30px;
padding: 5px 9px;
border-radius: 4px;
color: #eee;
font-size: 13px;
cursor: pointer;
white-space: nowrap;
}
.mv-secret-save {
background: #2f6336;
border: 1px solid #4c9b55;
}
.mv-secret-delete {
background: #653131;
border: 1px solid #a44;
}
.mv-secret-add {
width: 100%;
text-align: left;
color: #ccc;
background: #222;
border: 1px dashed #5a5a5a;
}
.mv-secret-save-notice {
margin-top: 4px;
min-height: 18px;
color: #ff4d4f;
font-size: 13px;
font-weight: 600;
text-align: center;
opacity: 0;
transition: opacity 0.4s ease;
pointer-events: none;
}
.mv-secret-save-notice-visible {
opacity: 1;
}
.mv-secret-save-notice-fade {
opacity: 0;
}
@media (max-width: 640px) {
.mv-secret-row {
grid-template-columns: 1fr;
}
.mv-secret-button,
.mv-secret-add {
width: 100%;
}
}
+196
View File
@@ -0,0 +1,196 @@
import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js";
const cssUrl = new URL("./modelverse_secrets.css", import.meta.url);
if (!document.querySelector(`link[href="${cssUrl}"]`)) {
document.head.appendChild(Object.assign(document.createElement("link"), { rel: "stylesheet", href: cssUrl }));
}
const NODE_NAME = "UCloud ModelVerse Secret Client";
const WIDGET_NAME = "secret";
const ENDPOINT = "/modelverse-secrets";
let cachedSecretNames = [];
function el(tag, props = {}, ...children) {
const node = Object.assign(document.createElement(tag), props);
node.append(...children);
return node;
}
async function fetchSecrets() {
const response = await api.fetchApi(ENDPOINT, { method: "GET" });
return await response.json();
}
async function refreshSecretNamesCache() {
const secrets = await fetchSecrets();
cachedSecretNames = Object.keys(secrets);
return cachedSecretNames;
}
async function refreshSecretDropdowns(preferredValue) {
const names = await refreshSecretNamesCache();
for (const node of app.graph._nodes ?? []) {
if (node.comfyClass !== NODE_NAME && node.type !== NODE_NAME) {
continue;
}
const widget = node.widgets?.find((item) => item.name === WIDGET_NAME);
if (!widget) {
continue;
}
widget.options.values = names.slice();
if (preferredValue && names.includes(preferredValue)) {
widget.value = preferredValue;
} else if (names.length && !names.includes(widget.value)) {
widget.value = names[0];
} else if (!names.length) {
widget.value = "";
}
if (typeof widget.callback === "function") {
try { widget.callback(widget.value); } catch (e) { /* noop */ }
}
node.setDirtyCanvas?.(true, true);
}
app.graph?.setDirtyCanvas?.(true, true);
}
function buildSecretRow(name = "", value = "", isNew = false, onSaved) {
let originalName = name;
const nameInput = el("input", { className: "mv-secret-input", placeholder: "Secret name", value: name });
const valueInput = el("input", { className: "mv-secret-input", placeholder: "API key", value, type: "password", autocomplete: "off" });
const saveButton = el("button", { className: "mv-secret-button mv-secret-save", textContent: isNew ? "Add" : "Save" });
const deleteButton = el("button", { className: "mv-secret-button mv-secret-delete", textContent: "Delete" });
const row = el("div", { className: "mv-secret-row" }, nameInput, valueInput, saveButton, deleteButton);
saveButton.onclick = async () => {
const key = nameInput.value.trim();
if (!key) {
nameInput.focus();
return;
}
const response = await api.fetchApi(ENDPOINT, {
method: "POST",
body: JSON.stringify({ key, value: valueInput.value }),
});
if (!response.ok) {
return;
}
if (!isNew && originalName && originalName !== key) {
await api.fetchApi(`${ENDPOINT}/${encodeURIComponent(originalName)}`, { method: "DELETE" });
}
originalName = key;
await refreshSecretDropdowns(key);
if (typeof onSaved === "function") {
onSaved();
}
if (isNew) {
row.replaceWith(buildSecretRow(key, valueInput.value, false, onSaved));
return;
}
saveButton.textContent = "Saved";
setTimeout(() => {
saveButton.textContent = "Save";
}, 1200);
};
deleteButton.onclick = async () => {
if (!originalName) {
row.remove();
return;
}
const response = await api.fetchApi(`${ENDPOINT}/${encodeURIComponent(originalName)}`, { method: "DELETE" });
if (response.ok) {
row.remove();
await refreshSecretDropdowns();
}
};
return row;
}
function createSecretsModal(secrets) {
const saveNotice = el("div", { className: "mv-secret-save-notice" });
const showSaveNotice = () => {
saveNotice.textContent = "Once added, there is a 10-second delay before the item shows up in the dropdown.";
saveNotice.classList.remove("mv-secret-save-notice-fade");
void saveNotice.offsetWidth;
saveNotice.classList.add("mv-secret-save-notice-visible");
clearTimeout(saveNotice._hideTimer);
saveNotice._hideTimer = setTimeout(() => {
saveNotice.classList.add("mv-secret-save-notice-fade");
}, 10000);
};
const rows = el(
"div",
{ className: "mv-secret-rows" },
...Object.entries(secrets).map(([name, value]) => buildSecretRow(name, value, false, showSaveNotice)),
);
const addButton = el("button", {
className: "mv-secret-add",
textContent: "+ Add Secret",
onclick: () => {
const row = buildSecretRow("", "", true, showSaveNotice);
rows.appendChild(row);
row.querySelector("input")?.focus();
},
});
const closeButton = el("button", { className: "mv-secret-close", textContent: "x" });
const overlay = el("div", { className: "mv-secret-overlay" },
el("div", { className: "mv-secret-dialog" },
el("div", { className: "mv-secret-header" },
el("h3", { className: "mv-secret-title", textContent: "Modelverse Secrets" }),
closeButton,
),
rows,
addButton,
saveNotice,
),
);
closeButton.onclick = () => overlay.remove();
overlay.addEventListener("click", (event) => {
if (event.target === overlay) {
overlay.remove();
}
});
return overlay;
}
app.registerExtension({
name: "compshare.modelverse_secrets",
async nodeCreated(node) {
if (node.comfyClass !== NODE_NAME && node.type !== NODE_NAME) {
return;
}
const names = await refreshSecretNamesCache();
const index = node.widgets?.findIndex((item) => item.name === WIDGET_NAME) ?? -1;
const savedValue = index >= 0 ? node.widgets[index].value : undefined;
if (index >= 0) {
node.widgets.splice(index, 1);
}
const defaultValue = savedValue && names.includes(savedValue) ? savedValue : (names[0] ?? "");
const comboOptions = {
get values() {
return cachedSecretNames.slice();
},
set values(v) { /* ignore external writes; cache is the source of truth */ },
};
node.addWidget("combo", WIDGET_NAME, defaultValue, () => {}, comboOptions);
node.addWidget("button", "Edit Secrets", null, async () => {
await refreshSecretNamesCache();
document.body.appendChild(createSecretsModal(await fetchSecrets()));
});
},
});