Merge pull request #7 from yinjiajiu/main
feature:添加Kling,seedance,veo,happyhorse,vidu和Gemini系列模型,增加api key配置,防止泄漏
This commit is contained in:
+2
-1
@@ -6,4 +6,5 @@ py/modelverse_api/requests/__pycache__
|
||||
/venv
|
||||
.idea/
|
||||
.ace-tool/
|
||||
docs
|
||||
docs
|
||||
secrets.json
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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.")
|
||||
|
||||
@@ -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
@@ -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.")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
@@ -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",
|
||||
}
|
||||
@@ -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
@@ -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",
|
||||
}
|
||||
@@ -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",
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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}")
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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,4 +1,5 @@
|
||||
requests >= 2.32.4
|
||||
pillow >= 11
|
||||
numpy >= 2.0
|
||||
openai >= 1.107.0
|
||||
GitPython==3.1.45
|
||||
@@ -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%;
|
||||
}
|
||||
}
|
||||
@@ -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()));
|
||||
});
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user