merge modelscope image and edit node

This commit is contained in:
qnsh
2026-09-11 14:14:33 +08:00
parent 0a2d008586
commit ed3aac6f62
10 changed files with 524 additions and 645 deletions
+3 -1
View File
@@ -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
+3 -1
View File
@@ -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
-2
View File
@@ -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,
+27 -23
View File
@@ -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"
}
}
}
+11 -52
View File
@@ -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 @@
}
}
}
}
}
+9 -50
View File
@@ -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 @@
}
}
}
}
}
-260
View File
@@ -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)
+308 -256
View File
@@ -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
return torch.zeros(1, 512, 512, 3, dtype=torch.float32)
+103
View File
@@ -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}")
+60
View File
@@ -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;
};
},
});