From ed3aac6f62e8abad42aa52137d0799020ba38fd0 Mon Sep 17 00:00:00 2001 From: qnsh Date: Fri, 11 Sep 2026 14:14:33 +0800 Subject: [PATCH] merge modelscope image and edit node --- README.md | 4 +- README_zh_CN.md | 4 +- __init__.py | 2 - config.json.example | 50 +- locales/en/nodeDefs.json | 63 +-- locales/zh/nodeDefs.json | 59 +-- modelscope/modelscope_image_edit_node.py | 260 ----------- modelscope/modelscope_image_node.py | 564 +++++++++++++---------- utils/config_utils.py | 103 +++++ web/js/modelscope_image.js | 60 +++ 10 files changed, 524 insertions(+), 645 deletions(-) delete mode 100644 modelscope/modelscope_image_edit_node.py create mode 100644 web/js/modelscope_image.js diff --git a/README.md b/README.md index c3e04b0..0261dbd 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,9 @@ git clone https://github.com/ycyy/ComfyUI-YCYY-API.git ### modelscope-image -The ModelScope image generation interface only requires you to fill in the corresponding `api_key`. Other parameters remain unchanged. +`modelscope-image` uses an array to configure multiple ModelScope image API names. Image generation and editing share one node: leave `image` disconnected to generate an image, or connect it to edit an image. We recommend separate generation and editing `api-name` entries, each with the appropriate models. Changing `api-name` updates the node's model list. + +Legacy single-object `modelscope-image` configurations remain supported and use `default` as the api name. Move the former `modelscope-image-edit` settings into a separate entry in the `modelscope-image` array. For the official endpoint, normally only the corresponding `api_key` needs to be changed from the example. ### openai-text diff --git a/README_zh_CN.md b/README_zh_CN.md index f73052e..c5fd6f2 100644 --- a/README_zh_CN.md +++ b/README_zh_CN.md @@ -23,7 +23,9 @@ git clone https://github.com/ycyy/ComfyUI-YCYY-API.git ### modelscope-image -魔搭图片生成接口只需要填写对应的 `api_key` 其他参数保持不变即可 +`modelscope-image` 使用数组配置多个魔搭图片 API name。图片生成和图片编辑共用一个节点:不连接 `image` 时生成图片,连接 `image` 时编辑图片。建议分别创建生成和编辑两个 `api-name`,并在各自的 `models` 中配置对应类型的模型;切换 `api-name` 后节点会同步更新模型列表。 + +旧版单对象 `modelscope-image` 配置仍可使用,其 api name 显示为 `default`。原 `modelscope-image-edit` 配置需要迁移为 `modelscope-image` 数组中的独立配置项。通常只需要填写对应的 `api_key`,其他官方接口参数保持示例值即可。 ### openai-text diff --git a/__init__.py b/__init__.py index 865602b..642f11d 100644 --- a/__init__.py +++ b/__init__.py @@ -11,7 +11,6 @@ from .ollama.ollama_vlm_node import * from .ollama.ollama_llm_node import * from .options.ollama_llm_advanced_options_node import * from .modelscope.modelscope_image_node import * -from .modelscope.modelscope_image_edit_node import * from .options.config_options_node import * from .options.gemini_speaker_options_node import * from .options.gemini_batch_speakers_options_node import * @@ -37,7 +36,6 @@ class APIExtension(ComfyExtension): OllamaLLM, OllamaVLM, ModelScopeImage, - ModelScopeImageEdit, ConfigOptions, ProxyOptions, GeminiImagePreset, diff --git a/config.json.example b/config.json.example index ba71f89..85ce869 100644 --- a/config.json.example +++ b/config.json.example @@ -109,31 +109,35 @@ "gpt-oss:120b" ] }, - "modelscope-image": { - "base_url": "https://api-inference.modelscope.cn", - "api_key": "put your token here", - "timeout": 300, - "models": [ - "Tongyi-MAI/Z-Image-Turbo", - "black-forest-labs/FLUX.1-Krea-dev", - "Qwen/Qwen-Image-2512", - "ideogram-ai/ideogram-4-fp8", - "krea/Krea-2-Turbo" - ] - }, - "modelscope-image-edit": { - "base_url": "https://api-inference.modelscope.cn", - "api_key": "put your token here", - "timeout": 300, - "models": [ - "Qwen/Qwen-Image-Edit-2511", - "black-forest-labs/FLUX.2-klein-9B", - "FireRedTeam/FireRed-Image-Edit-1.1" - ] - }, + "modelscope-image": [ + { + "api-name": "ModelScope Image Generation", + "base_url": "https://api-inference.modelscope.cn", + "api_key": "put your token here", + "timeout": 300, + "models": [ + "Tongyi-MAI/Z-Image-Turbo", + "black-forest-labs/FLUX.1-Krea-dev", + "Qwen/Qwen-Image-2512", + "ideogram-ai/ideogram-4-fp8", + "krea/Krea-2-Turbo" + ] + }, + { + "api-name": "ModelScope Image Editing", + "base_url": "https://api-inference.modelscope.cn", + "api_key": "put your token here", + "timeout": 300, + "models": [ + "Qwen/Qwen-Image-Edit-2511", + "black-forest-labs/FLUX.2-klein-9B", + "FireRedTeam/FireRed-Image-Edit-1.1" + ] + } + ], "proxy": { "enable": false, "http": "", "https": "http://127.0.0.1:7890" } -} \ No newline at end of file +} diff --git a/locales/en/nodeDefs.json b/locales/en/nodeDefs.json index 9aaaad0..bfb125a 100644 --- a/locales/en/nodeDefs.json +++ b/locales/en/nodeDefs.json @@ -609,7 +609,7 @@ }, "YCYY_ModelScope_Image_API": { "display_name": "ModelScope Image API", - "description": "This node uses the ModelScope API to generate images.", + "description": "Generate or edit images through the ModelScope API. Connecting an image enables edit mode.", "inputs": { "config_options": { "name": "config_options", @@ -621,61 +621,16 @@ }, "prompt": { "name": "prompt", - "tooltip": "Image generation positive prompt" - }, - "negative_prompt": { - "name": "negative_prompt", - "tooltip": "Image generation negative prompt" - }, - "model": { - "name": "model" - }, - "width": { - "name": "width" - }, - "height": { - "name": "height" - }, - "steps": { - "name": "steps" - }, - "guidance": { - "name": "guidance" - } - }, - "outputs": { - "0": { - "name": "Image" - }, - "1": { - "name": "String" - } - } - }, - "YCYY_ModelScope_Image_Edit_API": { - "display_name": "ModelScope Image Edit API", - "description": "This node uses the ModelScope API to edit an input image.", - "inputs": { - "image": { - "name": "image", - "tooltip": "Input image to edit" - }, - "config_options": { - "name": "config_options", - "tooltip": "Optional configuration override from YCYY API Config Options" - }, - "proxy_options": { - "name": "proxy_options", - "tooltip": "Optional proxy configuration override from YCYY API Proxy Options" - }, - "prompt": { - "name": "prompt", - "tooltip": "Image editing instruction" + "tooltip": "Prompt used to generate or edit the image" }, "negative_prompt": { "name": "negative_prompt", "tooltip": "Negative prompt" }, + "api_name": { + "name": "api name", + "tooltip": "Select a ModelScope image API name" + }, "model": { "name": "model" }, @@ -690,6 +645,10 @@ }, "guidance": { "name": "guidance" + }, + "image": { + "name": "image", + "tooltip": "Optional source image. Connect it to edit an image; leave it disconnected to generate one" } }, "outputs": { @@ -715,4 +674,4 @@ } } } -} \ No newline at end of file +} diff --git a/locales/zh/nodeDefs.json b/locales/zh/nodeDefs.json index bd011b9..cc6ab43 100644 --- a/locales/zh/nodeDefs.json +++ b/locales/zh/nodeDefs.json @@ -610,7 +610,7 @@ }, "YCYY_ModelScope_Image_API": { "display_name": "魔搭图像API", - "description": "该节点使用 ModelScope API 生成图像", + "description": "通过 ModelScope API 生成或编辑图像;连接图像后启用编辑模式", "inputs": { "config_options": { "name": "配置选项", @@ -622,11 +622,15 @@ }, "prompt": { "name": "prompt", - "tooltip": "图像生成正向提示" + "tooltip": "用于生成或编辑图像的提示词" }, "negative_prompt": { "name": "negative_prompt", - "tooltip": "图像生成负向提示" + "tooltip": "负向提示词" + }, + "api_name": { + "name": "api name", + "tooltip": "选择 ModelScope 图像 API 名称" }, "model": { "name": "模型" @@ -642,55 +646,10 @@ }, "guidance": { "name": "提示词引导系数" - } - }, - "outputs": { - "0": { - "name": "图像" }, - "1": { - "name": "字符串" - } - } - }, - "YCYY_ModelScope_Image_Edit_API": { - "display_name": "魔搭图像编辑API", - "description": "该节点使用 ModelScope API 编辑输入图像", - "inputs": { "image": { "name": "输入图像", - "tooltip": "要编辑的输入图像" - }, - "config_options": { - "name": "配置选项", - "tooltip": "可选配置覆盖选项,来自 YCYY API 配置选项" - }, - "proxy_options": { - "name": "代理选项", - "tooltip": "可选代理覆盖选项,来自 YCYY API 代理选项" - }, - "prompt": { - "name": "prompt", - "tooltip": "图像编辑指令" - }, - "negative_prompt": { - "name": "negative_prompt", - "tooltip": "负向提示" - }, - "model": { - "name": "模型" - }, - "width": { - "name": "宽度" - }, - "height": { - "name": "高度" - }, - "steps": { - "name": "采样步数" - }, - "guidance": { - "name": "提示词引导系数" + "tooltip": "可选源图像;连接后编辑图像,不连接时生成图像" } }, "outputs": { @@ -716,4 +675,4 @@ } } } -} \ No newline at end of file +} diff --git a/modelscope/modelscope_image_edit_node.py b/modelscope/modelscope_image_edit_node.py deleted file mode 100644 index 7fd377a..0000000 --- a/modelscope/modelscope_image_edit_node.py +++ /dev/null @@ -1,260 +0,0 @@ -import json -import os -import time -from io import BytesIO -from typing import Dict, List, Optional, Tuple - -import requests -import torch -from PIL import Image -from comfy_api.latest import io - -from ..utils.config_utils import get_config_section -from ..utils.image_utils import pil_to_tensor, tensor_to_base64_string - - -class ModelScopeImageEdit(io.ComfyNode): - """Edit one or more input images with a ModelScope image model.""" - - _CONFIG_SECTION = "modelscope-image-edit" - _DEFAULT_MODEL = "Qwen/Qwen-Image-Edit-2511" - - @classmethod - def _load_models_from_config(cls) -> List[str]: - try: - config_path = os.path.join(os.path.dirname(__file__), "..", "config.json") - with open(config_path, "r", encoding="utf-8") as config_file: - models = json.load(config_file).get(cls._CONFIG_SECTION, {}).get("models") - if isinstance(models, list) and models: - return models - except (OSError, ValueError, TypeError): - pass - return [cls._DEFAULT_MODEL] - - @classmethod - def _load_config_credentials(cls, config_options=None) -> Tuple[str, str, int]: - """Load edit endpoint credentials, allowing Config Options overrides.""" - config = get_config_section(cls._CONFIG_SECTION) - if not config: - raise ValueError(f"Missing '{cls._CONFIG_SECTION}' section in config file") - - config_options = config_options or {} - base_url = str(config_options.get("base_url") or config.get("base_url") or "").strip() - api_key = str(config_options.get("api_key") or config.get("api_key") or "").strip() - timeout = config_options.get("timeout") or config.get("timeout", 300) - try: - timeout = int(timeout) - except (TypeError, ValueError): - timeout = 300 - if not base_url: - raise ValueError(f"Missing 'base_url' in {cls._CONFIG_SECTION} section") - if not api_key: - raise ValueError(f"Missing 'api_key' in {cls._CONFIG_SECTION} section") - return base_url.rstrip("/"), api_key, timeout - - @classmethod - def _get_proxy_config(cls, proxy_options=None) -> Optional[Dict[str, str]]: - if proxy_options is not None: - if not proxy_options.get("enable", False): - return None - proxies = { - key: proxy_options[key].strip() - for key in ("http", "https") - if isinstance(proxy_options.get(key), str) and proxy_options[key].strip() - } - return proxies or None - - try: - proxy_config = get_config_section("proxy") or {} - if not proxy_config.get("enable", False): - return None - proxies = { - key: proxy_config[key] - for key in ("http", "https") - if proxy_config.get(key) - } - return proxies or None - except Exception: - return None - - @classmethod - def define_schema(cls) -> io.Schema: - model_options = cls._load_models_from_config() - return io.Schema( - node_id="YCYY_ModelScope_Image_Edit_API", - display_name="ModelScope Image Edit API", - category="YCYY/API/image", - inputs=[ - io.Image.Input( - id="image", - tooltip="Input image to edit", - ), - io.AnyType.Input( - id="config_options", - optional=True, - tooltip="Optional configuration override from YCYY API Config Options", - ), - io.AnyType.Input( - id="proxy_options", - optional=True, - tooltip="Optional proxy configuration override from YCYY API Proxy Options", - ), - io.String.Input(id="prompt", multiline=True, tooltip="Image editing instruction"), - io.String.Input(id="negative_prompt", multiline=True, tooltip="Negative prompt"), - io.Combo.Input( - id="model", - options=model_options, - default=model_options[0], - tooltip="Select ModelScope image editing model", - ), - io.Int.Input(id="width", min=64, max=2048, default=1024, step=8), - io.Int.Input(id="height", min=64, max=2048, default=1024, step=8), - io.Int.Input(id="steps", min=1, max=100, default=30, step=1), - io.Float.Input(id="guidance", min=1.5, max=20, default=3.5, step=0.1), - io.Int.Input( - id="seed", - min=0, - max=2147483647, - default=0, - control_after_generate=True, - ), - ], - outputs=[io.Image.Output(), io.String.Output()], - description="This node uses the ModelScope API to edit an input image.", - ) - - @classmethod - def execute( - cls, - image, - prompt, - negative_prompt, - model, - width, - height, - steps, - guidance, - seed, - config_options=None, - proxy_options=None, - ) -> io.NodeOutput: - if image is None: - raise ValueError("image cannot be empty") - if not prompt or not prompt.strip(): - raise ValueError("prompt cannot be empty") - base_url, api_key, timeout = cls._load_config_credentials(config_options) - return cls._edit_images( - base_url, - api_key, - image, - prompt, - negative_prompt, - model, - width, - height, - steps, - guidance, - seed, - timeout, - cls._get_proxy_config(proxy_options), - ) - - @classmethod - def _edit_images( - cls, - base_url, - api_key, - image, - prompt, - negative_prompt, - model, - width, - height, - steps, - guidance, - seed, - timeout, - proxies, - ) -> io.NodeOutput: - api_url = f"{base_url}/v1/images/generations" - headers = { - "Authorization": f"Bearer {api_key}", - "Content-Type": "application/json", - "X-ModelScope-Async-Mode": "true", - } - # ModelScope accepts the source image as an OpenAI-compatible data URL. - image_data = tensor_to_base64_string(image[0].unsqueeze(0) if image.ndim == 4 else image) - payload = { - "model": model, - "prompt": prompt, - "image_url": f"data:image/png;base64,{image_data}", - "size": f"{width}x{height}", - "steps": steps, - "guidance": guidance, - "seed": seed, - } - if negative_prompt: - payload["negative_prompt"] = negative_prompt - - try: - response = requests.post( - api_url, - headers=headers, - json=payload, - timeout=timeout, - proxies=proxies, - ) - if response.status_code != 200: - raise RuntimeError(f"HTTP {response.status_code}: {response.text}") - task_id = response.json().get("task_id") - if not task_id: - raise RuntimeError("ModelScope response did not contain task_id") - - output_image_url, task_data = cls._wait_for_task( - base_url, api_key, task_id, timeout, proxies - ) - output_response = requests.get(output_image_url, timeout=timeout, proxies=proxies) - output_response.raise_for_status() - result_image = Image.open(BytesIO(output_response.content)).convert("RGB") - return io.NodeOutput( - pil_to_tensor(result_image), - json.dumps(task_data, ensure_ascii=False), - ) - except Exception as error: - raise RuntimeError( - json.dumps( - {"success": False, "message": f"ModelScope image edit failed: {error}"}, - ensure_ascii=False, - ) - ) from error - - @classmethod - def _wait_for_task(cls, base_url, api_key, task_id, timeout, proxies): - deadline = time.monotonic() + timeout - while time.monotonic() < deadline: - response = requests.get( - f"{base_url}/v1/tasks/{task_id}", - headers={ - "Authorization": f"Bearer {api_key}", - "X-ModelScope-Task-Type": "image_generation", - }, - timeout=timeout, - proxies=proxies, - ) - if response.status_code != 200: - raise RuntimeError(f"Task query HTTP {response.status_code}: {response.text}") - data = response.json() - status = data.get("task_status") - if status == "SUCCEED": - output_images = data.get("output_images") or [] - if output_images: - return output_images[0], data - raise RuntimeError("Task succeeded without output image") - if status == "FAILED": - raise RuntimeError(data.get("message") or "Image editing task failed") - time.sleep(min(5, max(0, deadline - time.monotonic()))) - raise TimeoutError("Timed out waiting for ModelScope image editing task") - - @classmethod - def _create_empty_image(cls): - return torch.zeros(1, 512, 512, 3, dtype=torch.float32) diff --git a/modelscope/modelscope_image_node.py b/modelscope/modelscope_image_node.py index c487fe5..b735c67 100644 --- a/modelscope/modelscope_image_node.py +++ b/modelscope/modelscope_image_node.py @@ -1,334 +1,386 @@ -import os import json import time +from io import BytesIO +from typing import Dict, List, Optional, Tuple + import requests import torch -import numpy as np from PIL import Image -from io import BytesIO -from typing import Optional, Dict, List, Tuple -from comfy_api.latest import ComfyExtension, io -from ..utils.config_utils import get_config_section -from ..utils.image_utils import pil_to_tensor +from comfy_api.latest import io + +try: + from ..utils.config_utils import ( + DEFAULT_MODELSCOPE_IMAGE_MODELS, + get_config_section, + get_modelscope_image_api_config, + get_modelscope_image_apis, + ) + from ..utils.image_utils import pil_to_tensor, tensor_to_base64_string +except (ImportError, ValueError): + from utils.config_utils import ( + DEFAULT_MODELSCOPE_IMAGE_MODELS, + get_config_section, + get_modelscope_image_api_config, + get_modelscope_image_apis, + ) + from utils.image_utils import pil_to_tensor, tensor_to_base64_string + + +try: + from aiohttp import web + from server import PromptServer + + @PromptServer.instance.routes.get("/ycyy/modelscope-image/apis/all") + async def get_all_modelscope_image_apis(request): + try: + return web.json_response([ + {"api-name": item["api-name"], "models": item["models"]} + for item in get_modelscope_image_apis() + ]) + except Exception as exc: + return web.json_response({"error": str(exc)}, status=500) +except Exception: + pass + + +DEFAULT_MODELS = list(DEFAULT_MODELSCOPE_IMAGE_MODELS) + class ModelScopeImage(io.ComfyNode): - """ - 这个节点使用 ModelScope API 生成图像 - """ + """Generate or edit an image through the asynchronous ModelScope API.""" + @classmethod - def _load_models_from_config(cls) -> List[str]: - """ - 从 config.json 中加载模型列表 - 如果获取不到,返回默认模型列表 - """ + def _load_models_from_config(cls, api_name: Optional[str] = None) -> List[str]: try: - config_path = os.path.join(os.path.dirname(__file__), '..', "config.json") - if not os.path.exists(config_path): - return ["Tongyi-MAI/Z-Image-Turbo"] - - with open(config_path, 'r', encoding='utf-8') as f: - config = json.load(f) - - if 'modelscope-image' in config and 'models' in config['modelscope-image']: - models = config['modelscope-image']['models'] - if isinstance(models, list) and len(models) > 0: - return models - - return ["Tongyi-MAI/Z-Image-Turbo"] + apis = get_modelscope_image_apis() + if api_name: + for item in apis: + if item["api-name"] == api_name: + return item.get("models") or list(DEFAULT_MODELS) + models = list(dict.fromkeys( + model for item in apis for model in item.get("models", []) + )) + return models or list(DEFAULT_MODELS) except Exception: - return ["Tongyi-MAI/Z-Image-Turbo"] + return list(DEFAULT_MODELS) @classmethod - def _load_config_credentials(cls, config_options=None) -> Tuple[str, str, int]: - """ - 从 config.json 中加载并验证 API 凭据,如果提供了 config_options 则优先使用 - 返回 (base_url, api_key, timeout) 元组 - """ - # 如果提供了配置覆盖,则使用覆盖配置 - if config_options is not None: - base_url = config_options.get('base_url', '').strip() - api_key = config_options.get('api_key', '').strip() - timeout = config_options.get('timeout', 300) + def _load_config_credentials( + cls, + api_name: Optional[str] = None, + config_options: Optional[dict] = None, + ) -> Tuple[str, str, int]: + try: + api_config = get_modelscope_image_api_config(api_name) + except Exception: + apis = get_modelscope_image_apis() + api_config = apis[0] if apis else { + "base_url": "https://api-inference.modelscope.cn", + "api_key": "", + "timeout": 300, + } - # 如果覆盖配置中有有效的 base_url 和 api_key,则直接返回 - if base_url and api_key: - return base_url, api_key, timeout + base_url = str( + api_config.get("base_url") or "https://api-inference.modelscope.cn" + ).strip() + api_key = str(api_config.get("api_key") or "").strip() + timeout = api_config.get("timeout", 300) - # 否则从配置文件加载 - config_path = os.path.join(os.path.dirname(__file__), '..', "config.json") - - # 检查配置文件是否存在 - if not os.path.exists(config_path): - raise FileNotFoundError(f"Config file not found: {config_path}") + config_options = config_options or {} + override_base_url = str(config_options.get("base_url") or "").strip() + override_api_key = str(config_options.get("api_key") or "").strip() + if override_base_url: + base_url = override_base_url + if override_api_key: + api_key = override_api_key + if config_options.get("timeout"): + timeout = config_options["timeout"] try: - with open(config_path, 'r', encoding='utf-8') as f: - config = json.load(f) - - # 检查是否存在 modelscope 配置段 - if 'modelscope-image' not in config: - raise ValueError("Missing 'modelscope-image' section in config file") - - modelscope_image_config = config['modelscope-image'] - - # 获取并验证 base_url - if 'base_url' not in modelscope_image_config: - raise ValueError("Missing 'base_url' in modelscope-image section") - base_url = modelscope_image_config['base_url'].strip() if isinstance(modelscope_image_config['base_url'], str) else str(modelscope_image_config['base_url']).strip() - if not base_url: - raise ValueError("base_url cannot be empty") - - # 获取并验证 api_key - if 'api_key' not in modelscope_image_config: - raise ValueError("Missing 'api_key' in modelscope section") - api_key = modelscope_image_config['api_key'].strip() if isinstance(modelscope_image_config['api_key'], str) else str(modelscope_image_config['api_key']).strip() - if not api_key: - raise ValueError("api_key cannot be empty") - - # 获取 timeout 参数,默认值为 300 秒 - timeout = modelscope_image_config.get('timeout', 300) - if isinstance(timeout, str): - try: - timeout = int(timeout) - except ValueError: - timeout = 300 - - # 如果有配置覆盖,则使用覆盖的值(如果提供了) - if config_options is not None: - if config_options.get('base_url', '').strip(): - base_url = config_options['base_url'].strip() - if config_options.get('api_key', '').strip(): - api_key = config_options['api_key'].strip() - if config_options.get('timeout'): - timeout = config_options['timeout'] - - return base_url, api_key, timeout - - except FileNotFoundError: - raise - except ValueError: - raise - except json.JSONDecodeError as e: - raise ValueError(f"Invalid JSON in config file: {str(e)}") - except Exception as e: - raise ValueError(f"Config loading error: {str(e)}") + timeout = int(timeout) + except (TypeError, ValueError): + timeout = 300 + if timeout <= 0: + timeout = 300 + if not base_url: + raise ValueError("ModelScope base_url cannot be empty") + if not api_key: + raise ValueError( + "ModelScope API key not found. Please provide an api_key in " + "config.json ('modelscope-image') or via API Config Options." + ) + return base_url, api_key, timeout @classmethod - def _get_proxy_config(cls, proxy_options=None) -> Optional[Dict]: - """ - 从 config.json 中获取代理配置,如果提供了 proxy_options 则优先使用 - 返回 proxies 字典或 None - """ - # 如果提供了代理覆盖配置 + def _get_proxy_config( + cls, proxy_options: Optional[dict] = None + ) -> Optional[Dict[str, str]]: if proxy_options is not None: - if not proxy_options.get('enable', False): + if not proxy_options.get("enable", False): return None + proxies = { + key: proxy_options[key].strip() + for key in ("http", "https") + if isinstance(proxy_options.get(key), str) + and proxy_options[key].strip() + } + return proxies or None - proxies = {} - if proxy_options.get('http', '').strip(): - proxies['http'] = proxy_options['http'].strip() - if proxy_options.get('https', '').strip(): - proxies['https'] = proxy_options['https'].strip() - - return proxies if proxies else None - - # 否则从配置文件加载 try: - proxy_config = get_config_section('proxy') - if not proxy_config or not proxy_config.get('enable', False): + proxy_config = get_config_section("proxy") or {} + if not proxy_config.get("enable", False): return None - - proxies = {} - if proxy_config.get('http'): - proxies['http'] = proxy_config['http'] - if proxy_config.get('https'): - proxies['https'] = proxy_config['https'] - - return proxies if proxies else None + proxies = { + key: proxy_config[key] + for key in ("http", "https") + if proxy_config.get(key) + } + return proxies or None except Exception: return None @classmethod def define_schema(cls) -> io.Schema: - """ - 返回一个包含该节点所有信息的模式(schema) - """ - # 从配置文件加载模型列表 - model_options = cls._load_models_from_config() - default_model = model_options[0] + try: + apis = get_modelscope_image_apis() + api_names = [item["api-name"] for item in apis] + models = list(dict.fromkeys( + model for item in apis for model in item.get("models", []) + )) + except Exception: + api_names = ["default"] + models = list(DEFAULT_MODELS) + if not api_names: + api_names = ["default"] + if not models: + models = list(DEFAULT_MODELS) return io.Schema( node_id="YCYY_ModelScope_Image_API", display_name="ModelScope Image API", category="YCYY/API/image", inputs=[ - io.AnyType.Input( - id="config_options", - optional=True, - tooltip="Optional configuration override from YCYY API Config Options" - ), - io.AnyType.Input( - id="proxy_options", - optional=True, - tooltip="Optional proxy configuration override from YCYY API Proxy Options" - ), io.String.Input( id="prompt", multiline=True, - tooltip="Image generation positive prompt" + tooltip="Prompt used to generate or edit the image.", ), io.String.Input( id="negative_prompt", multiline=True, - tooltip="Image generation negative prompt" + tooltip="Negative prompt.", + ), + io.Combo.Input( + id="api_name", + options=api_names, + default=api_names[0], + tooltip="Select a ModelScope image API name.", ), io.Combo.Input( id="model", - options=model_options, - default=default_model, - tooltip="Select ModelScope image generation model" - ), - io.Int.Input( - id="width", - min=64, - max=2048, - default=1024, - step=8 - ), - io.Int.Input( - id="height", - min=64, - max=2048, - default=1024, - step=8 - ), - io.Int.Input( - id="steps", - min=1, - max=100, - default=30, - step=1 + options=models, + default=models[0], + tooltip="Select a model from the chosen API name.", ), + io.Int.Input(id="width", min=64, max=2048, default=1024, step=8), + io.Int.Input(id="height", min=64, max=2048, default=1024, step=8), + io.Int.Input(id="steps", min=1, max=100, default=30, step=1), io.Float.Input( - id="guidance", - min=1.5, - max=20, - default=3.5, - step=0.1 + id="guidance", min=1.5, max=20, default=3.5, step=0.1 ), io.Int.Input( id="seed", min=0, max=2147483647, default=0, - control_after_generate=True - ) + control_after_generate=True, + ), + io.Image.Input( + id="image", + optional=True, + tooltip="Optional source image. Connect it to edit an image; leave it disconnected to generate one.", + ), + io.AnyType.Input( + id="config_options", + optional=True, + tooltip="Optional configuration override from YCYY API Config Options.", + ), + io.AnyType.Input( + id="proxy_options", + optional=True, + tooltip="Optional proxy configuration override from YCYY API Proxy Options.", + ), ], - outputs=[ - io.Image.Output(), - io.String.Output() - ], - description="This node uses the ModelScope API to generate images." + outputs=[io.Image.Output(), io.String.Output()], + description="Generate or edit images through the ModelScope API. Connecting an image enables edit mode.", ) @classmethod - def execute(cls, prompt, negative_prompt, model, width, height, steps, guidance, seed, config_options=None, proxy_options=None) -> io.NodeOutput: - """ - 节点执行入口 - """ - base_url, api_key, timeout = cls._load_config_credentials(config_options) - proxies = cls._get_proxy_config(proxy_options) + def execute( + cls, + prompt, + negative_prompt, + model, + width, + height, + steps, + guidance, + seed, + api_name=None, + image=None, + config_options=None, + proxy_options=None, + ) -> io.NodeOutput: if not prompt or not prompt.strip(): - raise Exception("prompt cannot be empty") - return cls._generate_images(base_url,api_key,prompt,negative_prompt,model,width,height, steps, guidance, seed,timeout,proxies) + raise ValueError("prompt cannot be empty") + + base_url, api_key, timeout = cls._load_config_credentials( + api_name=api_name, config_options=config_options + ) + return cls._request_image( + base_url=base_url, + api_key=api_key, + prompt=prompt, + negative_prompt=negative_prompt, + model=model, + width=width, + height=height, + steps=steps, + guidance=guidance, + seed=seed, + image=image, + timeout=timeout, + proxies=cls._get_proxy_config(proxy_options), + ) + @classmethod - def _generate_images(cls,base_url,api_key,prompt,negative_prompt,model,width,height, steps, guidance, seed,timeout,proxies)-> io.NodeOutput: - # 构建返回参数 - result_image = cls._create_empty_image() - result_message = json.dumps({ - "success": False, - "message": "API request returns an error" - }) - output_image_url = None - # 构建请求 URL - api_url = f"{base_url}/v1/images/generations" - # 构建请求头 + def _request_image( + cls, + base_url, + api_key, + prompt, + negative_prompt, + model, + width, + height, + steps, + guidance, + seed, + image, + timeout, + proxies, + ) -> io.NodeOutput: + service_root = cls._normalize_base_url(base_url) + api_url = f"{service_root}/v1/images/generations" headers = { "Authorization": f"Bearer {api_key}", "Content-Type": "application/json", - "X-ModelScope-Async-Mode": "true" + "X-ModelScope-Async-Mode": "true", } - # 构建请求体 + mode = "edit" if image is not None else "generation" payload = { "model": model, "prompt": prompt, "size": f"{width}x{height}", "steps": steps, "guidance": guidance, - "seed": seed + "seed": seed, } - if negative_prompt is not None and negative_prompt: + if negative_prompt: payload["negative_prompt"] = negative_prompt + if image is not None: + image_data = tensor_to_base64_string(image) + payload["image_url"] = f"data:image/png;base64,{image_data}" + try: response = requests.post( - api_url, - headers=headers, - data=json.dumps(payload, ensure_ascii=False).encode('utf-8'), - timeout=timeout, - proxies=proxies + api_url, + headers=headers, + json=payload, + timeout=timeout, + proxies=proxies, ) if response.status_code != 200: - result_message = json.dumps({ - "success": False, - "message": f"API request returns an error.status_code:{response.status_code}.error_reason:{response.text}" - }) - raise Exception(result_message) - task_id = response.json()["task_id"] - if task_id is not None and task_id: - while True: - result = requests.get( - f"{base_url}/v1/tasks/{task_id}", - headers={ - 'Authorization': f'Bearer {api_key}', - 'X-ModelScope-Task-Type': 'image_generation' - }, - timeout=timeout - ) - if result.status_code != 200: - result_message = json.dumps({ - "success": False, - "message": f"API request returns an error.status_code:{result.status_code}.error_reason:{result.text}" - }) - raise Exception(result_message) - data = result.json() - if data["task_status"] == "SUCCEED": - output_image_url = data["output_images"][0] - break - elif data["task_status"] == "FAILED": - result_message = json.dumps({ - "success": False, - "message": "Image generation failed." - }) - break - time.sleep(5) - output_image_response = requests.get(output_image_url, timeout=timeout) - pil_image = Image.open(BytesIO(output_image_response.content)) - if pil_image.mode != 'RGB': - pil_image = pil_image.convert('RGB') - result_image = pil_to_tensor(pil_image) - result_message = json.dumps({ + raise RuntimeError(f"HTTP {response.status_code}: {response.text}") + task_id = response.json().get("task_id") + if not task_id: + raise RuntimeError("ModelScope response did not contain task_id") + + output_image_url, task_data = cls._wait_for_task( + service_root, api_key, task_id, timeout, proxies + ) + output_response = requests.get( + output_image_url, timeout=timeout, proxies=proxies + ) + output_response.raise_for_status() + result_image = Image.open(BytesIO(output_response.content)).convert("RGB") + result_info = { "success": True, - "message": "Image generation success.", - "image_url": output_image_url - }) - return io.NodeOutput(result_image,result_message) - except Exception as e: - raise Exception(result_message) + "message": f"Image {mode} success.", + "mode": mode, + "model": model, + "task_id": task_id, + "image_url": output_image_url, + "task": task_data, + } + return io.NodeOutput( + pil_to_tensor(result_image), + json.dumps(result_info, ensure_ascii=False), + ) + except Exception as error: + raise RuntimeError( + json.dumps( + { + "success": False, + "mode": mode, + "message": f"ModelScope image {mode} failed: {error}", + }, + ensure_ascii=False, + ) + ) from error + + @classmethod + def _wait_for_task(cls, service_root, api_key, task_id, timeout, proxies): + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + response = requests.get( + f"{service_root}/v1/tasks/{task_id}", + headers={ + "Authorization": f"Bearer {api_key}", + "X-ModelScope-Task-Type": "image_generation", + }, + timeout=timeout, + proxies=proxies, + ) + if response.status_code != 200: + raise RuntimeError( + f"Task query HTTP {response.status_code}: {response.text}" + ) + data = response.json() + status = data.get("task_status") + if status == "SUCCEED": + output_images = data.get("output_images") or [] + if output_images: + return output_images[0], data + raise RuntimeError("Task succeeded without output image") + if status == "FAILED": + raise RuntimeError(data.get("message") or "Image task failed") + remaining = deadline - time.monotonic() + if remaining > 0: + time.sleep(min(5, remaining)) + raise TimeoutError("Timed out waiting for ModelScope image task") + + @classmethod + def _normalize_base_url(cls, base_url: str) -> str: + clean_url = base_url.strip().rstrip("/") + for suffix in ("/v1/images/generations", "/v1/images", "/v1"): + if clean_url.endswith(suffix): + clean_url = clean_url[:-len(suffix)] + break + if not clean_url: + raise ValueError("ModelScope base_url cannot be empty") + return clean_url - # 创建空图像 @classmethod def _create_empty_image(cls): - try: - return torch.zeros(1, 512, 512, 3, dtype=torch.float32) - except Exception as e: - return None \ No newline at end of file + return torch.zeros(1, 512, 512, 3, dtype=torch.float32) diff --git a/utils/config_utils.py b/utils/config_utils.py index efd0ed3..ba29ecc 100644 --- a/utils/config_utils.py +++ b/utils/config_utils.py @@ -330,3 +330,106 @@ def get_openai_image_api_config(api_name=None, section_key="openai-image"): raise ValueError(f"Unknown API name: {api_name}") +DEFAULT_MODELSCOPE_IMAGE_MODELS = [ + "Tongyi-MAI/Z-Image-Turbo", + "black-forest-labs/FLUX.1-Krea-dev", + "Qwen/Qwen-Image-2512", + "ideogram-ai/ideogram-4-fp8", + "krea/Krea-2-Turbo", + "Qwen/Qwen-Image-Edit-2511", + "black-forest-labs/FLUX.2-klein-9B", + "FireRedTeam/FireRed-Image-Edit-1.1", +] + + +def get_modelscope_image_apis(section_key="modelscope-image"): + """Return normalized ModelScope image API configurations. + + ``modelscope-image`` is normally an array. A legacy single mapping is also + accepted so existing generation configurations keep working after the + generation and editing nodes are merged. + """ + raw = get_config_section(section_key) + if raw is None: + return [{ + "api-name": "default", + "base_url": "https://api-inference.modelscope.cn", + "api_key": "", + "timeout": 300, + "models": list(DEFAULT_MODELSCOPE_IMAGE_MODELS), + }] + if isinstance(raw, dict): + raw_items = [raw] + elif isinstance(raw, list): + raw_items = raw + else: + raise ValueError(f"{section_key} must be an object or array") + if not raw_items: + raise ValueError(f"{section_key} cannot be empty") + + result = [] + names = set() + for index, item in enumerate(raw_items): + if not isinstance(item, dict): + raise ValueError(f"{section_key}[{index}] must be an object") + + name = item.get("api-name") or item.get("api_name") + if not name: + if len(raw_items) == 1: + name = "default" + else: + raise ValueError(f"{section_key}[{index}] missing 'api-name'") + name = str(name).strip() + if not name: + raise ValueError(f"{section_key}[{index}] api-name cannot be empty") + if name in names: + raise ValueError(f"Duplicate api-name: {name}") + names.add(name) + + base_url = str( + item.get("base_url", "") or "https://api-inference.modelscope.cn" + ).strip() + if not base_url: + base_url = "https://api-inference.modelscope.cn" + + models = item.get("models") + if not isinstance(models, list) or not models: + models = list(DEFAULT_MODELSCOPE_IMAGE_MODELS) + else: + models = [str(model).strip() for model in models if str(model).strip()] + if not models: + models = list(DEFAULT_MODELSCOPE_IMAGE_MODELS) + + timeout = item.get("timeout", 300) + try: + timeout = int(timeout) + except (TypeError, ValueError): + timeout = 300 + if timeout <= 0: + timeout = 300 + + result.append({ + "api-name": name, + "base_url": base_url, + "api_key": str(item.get("api_key", "") or "").strip(), + "timeout": timeout, + "models": models, + }) + return result + + +def get_modelscope_image_api_names(section_key="modelscope-image"): + return [item["api-name"] for item in get_modelscope_image_apis(section_key)] + + +def get_modelscope_image_api_config(api_name=None, section_key="modelscope-image"): + apis = get_modelscope_image_apis(section_key) + if not apis: + raise ValueError(f"No configured APIs found in {section_key}") + if not api_name: + return apis[0] + for item in apis: + if item["api-name"] == api_name: + return item + raise ValueError(f"Unknown API name: {api_name}") + diff --git a/web/js/modelscope_image.js b/web/js/modelscope_image.js new file mode 100644 index 0000000..92fec7f --- /dev/null +++ b/web/js/modelscope_image.js @@ -0,0 +1,60 @@ +import { app } from "../../scripts/app.js"; +import { api } from "../../scripts/api.js"; + +const NODE_CLASS = "YCYY_ModelScope_Image_API"; +let apiMap = new Map(); + +async function loadApis() { + try { + const response = await api.fetchApi("/ycyy/modelscope-image/apis/all"); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const data = await response.json(); + apiMap = new Map((Array.isArray(data) ? data : []).map(item => [item["api-name"], item])); + } catch (error) { + console.error("[YCYY] Failed to load ModelScope Image API list:", error); + } +} + +function applyModels(node, apiName, keepModel = false) { + const selected = apiMap.get(apiName); + const modelWidget = node.widgets?.find(widget => widget.name === "model"); + if (!selected || !modelWidget) return; + const models = Array.isArray(selected.models) ? selected.models : []; + modelWidget.options.values = models; + if (!keepModel || !models.includes(modelWidget.value)) modelWidget.value = models[0] ?? ""; + app.canvas?.draw(true, true); +} + +app.registerExtension({ + name: "YCYY.ModelScope.Image", + async setup() { await loadApis(); }, + async beforeRegisterNodeDef(nodeType) { + if (nodeType.comfyClass !== NODE_CLASS) return; + const originalCreated = nodeType.prototype.onNodeCreated; + nodeType.prototype.onNodeCreated = function () { + const result = originalCreated?.apply(this, arguments); + const apiWidget = this.widgets?.find(widget => widget.name === "api_name"); + if (apiWidget) { + const originalCallback = apiWidget.callback; + apiWidget.callback = value => { + applyModels(this, value); + originalCallback?.call(this, value); + }; + setTimeout(() => applyModels(this, apiWidget.value, true), 0); + } + return result; + }; + const originalConfigure = nodeType.prototype.onConfigure; + nodeType.prototype.onConfigure = function () { + const result = originalConfigure?.apply(this, arguments); + const apiWidget = this.widgets?.find(widget => widget.name === "api_name"); + if (apiWidget && apiMap.size && !apiMap.has(apiWidget.value)) { + const fallback = apiMap.keys().next().value; + console.warn(`[YCYY] ModelScope Image API "${apiWidget.value}" no longer exists; using "${fallback}"`); + apiWidget.value = fallback; + } + if (apiWidget) applyModels(this, apiWidget.value, true); + return result; + }; + }, +});