From 36876b888fd5d26caa0c0a55aaeadb747144c62f Mon Sep 17 00:00:00 2001 From: yinjiajiu Date: Thu, 11 Jun 2026 14:43:02 +0800 Subject: [PATCH] =?UTF-8?q?feature:=E6=B7=BB=E5=8A=A0Kling,seedance,veo,ha?= =?UTF-8?q?ppyhorse,vidu=E5=92=8CGemini=E7=B3=BB=E5=88=97=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=EF=BC=8C=E5=A2=9E=E5=8A=A0api=20key=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=EF=BC=8C=E9=98=B2=E6=AD=A2=E6=B3=84=E6=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 3 +- README.md | 24 +- py/doubao_seedance_2.py | 141 ++++++++++++ py/flux_dev.py | 2 +- py/flux_kontext_max.py | 15 +- py/flux_kontext_max_t2i.py | 2 +- py/flux_kontext_pro.py | 17 +- py/flux_kontext_pro_t2i.py | 2 +- py/gemini_flash_image.py | 15 +- py/gemini_pro_image.py | 12 +- py/gpt_image_1.py | 2 +- py/gpt_image_1_edit.py | 2 +- py/happyhorse_i2v.py | 138 ++++++++++++ py/happyhorse_r2v.py | 156 +++++++++++++ py/happyhorse_t2v.py | 124 ++++++++++ py/kling_v3.py | 167 ++++++++++++++ py/kling_v3_omni.py | 166 ++++++++++++++ py/modelverse_api/client.py | 3 + .../requests/doubao_seedance_2.py | 198 ++++++++++++++++ .../requests/gemini_flash_image.py | 12 +- .../requests/gemini_pro_image.py | 6 +- py/modelverse_api/requests/kling_common.py | 52 +++++ py/modelverse_api/requests/kling_v3.py | 172 ++++++++++++++ py/modelverse_api/requests/kling_v3_omni.py | 155 +++++++++++++ py/nodes.py | 129 ++++++++++- py/openai.py | 33 +-- py/qwen_image_edit.py | 2 +- py/qwen_image_t2i.py | 2 +- py/veo_3_1.py | 211 ++++++++++++++++++ py/vidu_i2v.py | 12 +- py/vidu_ref2v.py | 10 +- py/vidu_startend2v.py | 12 +- py/vidu_t2v.py | 10 +- py/wan_ai_i2v.py | 2 +- py/wan_ai_t2v.py | 2 +- pyproject.toml | 4 +- requirements.txt | 1 + web/modelverse_secrets.css | 136 +++++++++++ web/modelverse_secrets.js | 196 ++++++++++++++++ 39 files changed, 2263 insertions(+), 85 deletions(-) create mode 100644 py/doubao_seedance_2.py create mode 100644 py/happyhorse_i2v.py create mode 100644 py/happyhorse_r2v.py create mode 100644 py/happyhorse_t2v.py create mode 100644 py/kling_v3.py create mode 100644 py/kling_v3_omni.py create mode 100644 py/modelverse_api/requests/doubao_seedance_2.py create mode 100644 py/modelverse_api/requests/kling_common.py create mode 100644 py/modelverse_api/requests/kling_v3.py create mode 100644 py/modelverse_api/requests/kling_v3_omni.py create mode 100644 py/veo_3_1.py create mode 100644 web/modelverse_secrets.css create mode 100644 web/modelverse_secrets.js diff --git a/.gitignore b/.gitignore index 278884e..78f6642 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,5 @@ py/modelverse_api/requests/__pycache__ /venv .idea/ .ace-tool/ -docs \ No newline at end of file +docs +secrets.json \ No newline at end of file diff --git a/README.md b/README.md index 8bd0d1d..2f04ba7 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/py/doubao_seedance_2.py b/py/doubao_seedance_2.py new file mode 100644 index 0000000..ec383fd --- /dev/null +++ b/py/doubao_seedance_2.py @@ -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", +} diff --git a/py/flux_dev.py b/py/flux_dev.py index 622bd23..af4bcd5 100644 --- a/py/flux_dev.py +++ b/py/flux_dev.py @@ -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, diff --git a/py/flux_kontext_max.py b/py/flux_kontext_max.py index bb548b3..64e742b 100644 --- a/py/flux_kontext_max.py +++ b/py/flux_kontext_max.py @@ -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.") diff --git a/py/flux_kontext_max_t2i.py b/py/flux_kontext_max_t2i.py index 24dcd01..2f407bb 100644 --- a/py/flux_kontext_max_t2i.py +++ b/py/flux_kontext_max_t2i.py @@ -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, diff --git a/py/flux_kontext_pro.py b/py/flux_kontext_pro.py index f5d9809..4aea240 100644 --- a/py/flux_kontext_pro.py +++ b/py/flux_kontext_pro.py @@ -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.") diff --git a/py/flux_kontext_pro_t2i.py b/py/flux_kontext_pro_t2i.py index b286450..a898fab 100644 --- a/py/flux_kontext_pro_t2i.py +++ b/py/flux_kontext_pro_t2i.py @@ -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, diff --git a/py/gemini_flash_image.py b/py/gemini_flash_image.py index 801b76d..d272895 100644 --- a/py/gemini_flash_image.py +++ b/py/gemini_flash_image.py @@ -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", } diff --git a/py/gemini_pro_image.py b/py/gemini_pro_image.py index f5cbffb..3d172ad 100644 --- a/py/gemini_pro_image.py +++ b/py/gemini_pro_image.py @@ -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', } diff --git a/py/gpt_image_1.py b/py/gpt_image_1.py index 1e28f5b..5afeea4 100644 --- a/py/gpt_image_1.py +++ b/py/gpt_image_1.py @@ -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( diff --git a/py/gpt_image_1_edit.py b/py/gpt_image_1_edit.py index 8235767..f05962d 100644 --- a/py/gpt_image_1_edit.py +++ b/py/gpt_image_1_edit.py @@ -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( diff --git a/py/happyhorse_i2v.py b/py/happyhorse_i2v.py new file mode 100644 index 0000000..89115bf --- /dev/null +++ b/py/happyhorse_i2v.py @@ -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", +} diff --git a/py/happyhorse_r2v.py b/py/happyhorse_r2v.py new file mode 100644 index 0000000..b06295a --- /dev/null +++ b/py/happyhorse_r2v.py @@ -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", +} diff --git a/py/happyhorse_t2v.py b/py/happyhorse_t2v.py new file mode 100644 index 0000000..2b0cdb4 --- /dev/null +++ b/py/happyhorse_t2v.py @@ -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", +} diff --git a/py/kling_v3.py b/py/kling_v3.py new file mode 100644 index 0000000..efc8b06 --- /dev/null +++ b/py/kling_v3.py @@ -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", +} diff --git a/py/kling_v3_omni.py b/py/kling_v3_omni.py new file mode 100644 index 0000000..f814e6b --- /dev/null +++ b/py/kling_v3_omni.py @@ -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", +} diff --git a/py/modelverse_api/client.py b/py/modelverse_api/client.py index 31fbc83..8c369e3 100644 --- a/py/modelverse_api/client.py +++ b/py/modelverse_api/client.py @@ -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} diff --git a/py/modelverse_api/requests/doubao_seedance_2.py b/py/modelverse_api/requests/doubao_seedance_2.py new file mode 100644 index 0000000..cda51a4 --- /dev/null +++ b/py/modelverse_api/requests/doubao_seedance_2.py @@ -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", + ] diff --git a/py/modelverse_api/requests/gemini_flash_image.py b/py/modelverse_api/requests/gemini_flash_image.py index 3de23ff..01693a0 100644 --- a/py/modelverse_api/requests/gemini_flash_image.py +++ b/py/modelverse_api/requests/gemini_flash_image.py @@ -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"] diff --git a/py/modelverse_api/requests/gemini_pro_image.py b/py/modelverse_api/requests/gemini_pro_image.py index 68506a2..30c5275 100644 --- a/py/modelverse_api/requests/gemini_pro_image.py +++ b/py/modelverse_api/requests/gemini_pro_image.py @@ -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)") diff --git a/py/modelverse_api/requests/kling_common.py b/py/modelverse_api/requests/kling_common.py new file mode 100644 index 0000000..f2f931e --- /dev/null +++ b/py/modelverse_api/requests/kling_common.py @@ -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() diff --git a/py/modelverse_api/requests/kling_v3.py b/py/modelverse_api/requests/kling_v3.py new file mode 100644 index 0000000..1fac6ca --- /dev/null +++ b/py/modelverse_api/requests/kling_v3.py @@ -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", + ] diff --git a/py/modelverse_api/requests/kling_v3_omni.py b/py/modelverse_api/requests/kling_v3_omni.py new file mode 100644 index 0000000..8cd174a --- /dev/null +++ b/py/modelverse_api/requests/kling_v3_omni.py @@ -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", + ] diff --git a/py/nodes.py b/py/nodes.py index 81d0cbf..1f2eac7 100644 --- a/py/nodes.py +++ b/py/nodes.py @@ -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' } diff --git a/py/openai.py b/py/openai.py index be87fc1..a455ac0 100644 --- a/py/openai.py +++ b/py/openai.py @@ -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: diff --git a/py/qwen_image_edit.py b/py/qwen_image_edit.py index be31614..762a513 100644 --- a/py/qwen_image_edit.py +++ b/py/qwen_image_edit.py @@ -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( diff --git a/py/qwen_image_t2i.py b/py/qwen_image_t2i.py index a0da6ca..07ba1a7 100644 --- a/py/qwen_image_t2i.py +++ b/py/qwen_image_t2i.py @@ -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( diff --git a/py/veo_3_1.py b/py/veo_3_1.py new file mode 100644 index 0000000..4f3639e --- /dev/null +++ b/py/veo_3_1.py @@ -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", +} diff --git a/py/vidu_i2v.py b/py/vidu_i2v.py index 60ef6b2..75f91c2 100644 --- a/py/vidu_i2v.py +++ b/py/vidu_i2v.py @@ -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: diff --git a/py/vidu_ref2v.py b/py/vidu_ref2v.py index a0892fc..d574d9e 100644 --- a/py/vidu_ref2v.py +++ b/py/vidu_ref2v.py @@ -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}") diff --git a/py/vidu_startend2v.py b/py/vidu_startend2v.py index 0d7c213..d9251de 100644 --- a/py/vidu_startend2v.py +++ b/py/vidu_startend2v.py @@ -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 = {} diff --git a/py/vidu_t2v.py b/py/vidu_t2v.py index 32261e8..3fb88af 100644 --- a/py/vidu_t2v.py +++ b/py/vidu_t2v.py @@ -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}") diff --git a/py/wan_ai_i2v.py b/py/wan_ai_i2v.py index b0f99b0..b243641 100644 --- a/py/wan_ai_i2v.py +++ b/py/wan_ai_i2v.py @@ -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") diff --git a/py/wan_ai_t2v.py b/py/wan_ai_t2v.py index e8fce43..367f262 100644 --- a/py/wan_ai_t2v.py +++ b/py/wan_ai_t2v.py @@ -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") diff --git a/pyproject.toml b/pyproject.toml index afd24d7..0938393 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/requirements.txt b/requirements.txt index 5add0cd..64365ed 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,5 @@ requests >= 2.32.4 pillow >= 11 +numpy >= 2.0 openai >= 1.107.0 GitPython==3.1.45 \ No newline at end of file diff --git a/web/modelverse_secrets.css b/web/modelverse_secrets.css new file mode 100644 index 0000000..a47d1b2 --- /dev/null +++ b/web/modelverse_secrets.css @@ -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%; + } +} diff --git a/web/modelverse_secrets.js b/web/modelverse_secrets.js new file mode 100644 index 0000000..f419398 --- /dev/null +++ b/web/modelverse_secrets.js @@ -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())); + }); + }, +});