15 Commits
Author SHA1 Message Date
Semon Xue a5acb9fd19 docs: fix date to 2026-9-7 2026-09-08 11:06:22 +08:00
Semon Xue 3544907a30 docs: update readme for aspect ratio labels in size presets 2026-09-08 11:04:51 +08:00
Semon Xue c6c013255d chore: bump version to 1.0.8 2026-09-08 11:04:46 +08:00
Semon Xue fc77130354 feat: add aspect ratio labels to size presets (e.g. 1254x1254(1:1,1K)) 2026-09-08 11:00:15 +08:00
Semon Xue a4bd47193b Bump 1.0.8: V3 schema with Combo size dropdown + custom_size override, nano-banana imageConfig passthrough 2026-09-08 02:19:39 +08:00
Semonxue b8a19c8e75 Merge pull request #12 from Semonxue/dev
docs: correct repository url in README files
2025-12-01 19:07:24 +08:00
Semonxue bc81ecd431 Merge pull request #9 from Semonxue/dev
Dev
2025-11-21 12:36:30 +08:00
Semonxue 8d2daf4fe8 Merge pull request #7 from ComfyNodePRs/publish
Add Github Action for Publishing to Comfy Registry
2025-11-11 00:20:42 +08:00
Semonxue e481964fd2 Merge pull request #6 from ComfyNodePRs/pyproject
Add pyproject.toml for Custom Node Registry
2025-11-11 00:16:01 +08:00
Semonxue 638a52e24d Update PublisherId in pyproject.toml 2025-11-11 00:03:57 +08:00
async () = process.env.GIT_USERNAME || (await ghUser()).email && (await ghUser()).name || DIE("Missing env.GIT_USERNAME") cea0d5cc0a chore(publish): Add Github Action for Publishing to Comfy Registry 2025-10-22 21:06:26 +00:00
async () = process.env.GIT_USERNAME || (await ghUser()).email && (await ghUser()).name || DIE("Missing env.GIT_USERNAME") 804703ef47 chore(pyproject): Add pyproject.toml for Custom Node Registry 2025-10-22 21:06:26 +00:00
Semonxue 64af5549e2 Merge pull request #4 from Semonxue/dev
feat: translate node info to English
2025-09-16 12:42:19 +08:00
Semonxue 12fc6bd99f Merge pull request #3 from Semonxue/dev
Dev
2025-09-13 13:42:48 +08:00
Semonxue eaf6a9da7e Merge pull request #2 from Semonxue/dev
Dev
2025-09-04 12:19:31 +08:00
6 changed files with 744 additions and 297 deletions
+28
View File
@@ -0,0 +1,28 @@
name: Publish to Comfy registry
on:
workflow_dispatch:
push:
branches:
- main
- master
paths:
- "pyproject.toml"
permissions:
issues: write
jobs:
publish-node:
name: Publish Custom Node to registry
runs-on: ubuntu-latest
if: ${{ github.repository_owner == 'Semonxue' }}
steps:
- name: Check out code
uses: actions/checkout@v4
with:
submodules: true
- name: Publish Custom Node
uses: Comfy-Org/publish-node-action@v1
with:
## Add your own personal access token to your Github Repository secrets and reference it here.
personal_access_token: ${{ secrets.REGISTRY_ACCESS_TOKEN }}
+2 -1
View File
@@ -11,6 +11,7 @@
写这个插件主要为了简化自己的工作,通过标准化的openai接口就可是接入llm或图片生成,最近用这个节点主要在玩 gemini-2.5-flash-image 的生成(没测过gemini官方接口,我接的是openai兼容端点),效果还不错。
## 最近更新
- 2026-9-7 **画幅比例标注**:图片节点的所有尺寸预设现在都包含直观的画幅比例标注,例如 `1254x1254(1:1,1K)`、`1024x1536(2:3,1K)`、`1672x941(16:9,1K)`。
- 2025-11-21 1.0.7 **支持新模型与尺寸比例**:为图像节点添加了对 `nano-banana`、`nano-banana-pro` 等模型的支持,并允许在非兼容模式下使用宽高比设置尺寸。
![](thumb/flexai-image-nanobananapro.jpg)
- 2025-9-13 1.0.6 图片节点接收图像uri内容时支持jpg类型,以支持部分seedream4的接口请求。
@@ -115,7 +116,7 @@ OPENAI_API_BASE_custom=https://your-api.example.com/v1
| `model` | 字符串 | 模型名称 (如 `dall-e-3`, `dall-e-2`) |
| `prompt` | 字符串 | 生成/编辑提示词 |
| `image_1-4` | 图片 | 可选图片(提供任意张则进入编辑模式) |
| `size` | 字符串 | 输出尺寸 (如 `1024x1024`) <br><br> **宽高比支持**:对于 `nano-banana` 和 `nano-banana-pro` 等特定模型,在**非兼容模式**下,此字段可以直接输入宽高比。支持的比例与对应的分辨率如下:<br> `1x1` → `1024x1024` <br> `2x3` → `832x1248` <br> `3x2` → `1248x832` <br> `3x4` → `864x1184` <br> `4x3` → `1184x864` <br> `4x5` → `896x1152` <br> `5x4` → `1152x896` <br> `9x16` → `768x1344` <br> `16x9` → `1344x768` <br> `21x9` → `1536x672` |
| `size` | 选择/字符串 | 带画幅比例标注的尺寸预设(如 `1254x1254(1:1,1K)`、`1024x1536(2:3,1K)`、`1672x941(16:9,1K)`)。下拉菜单涵盖所有常见比例,每个比例分 1K/2K/4K 三档。自由输入请用 `custom_size` 参数。<br><br> **宽高比支持**:对于 `nano-banana` 和 `nano-banana-pro` 等特定模型,在**非兼容模式**下,此字段可以直接输入宽高比。支持的比例与对应的分辨率如下:<br> `1x1` → `1024x1024` <br> `2x3` → `832x1248` <br> `3x2` → `1248x832` <br> `3x4` → `864x1184` <br> `4x3` → `1184x864` <br> `4x5` → `896x1152` <br> `5x4` → `1152x896` <br> `9x16` → `768x1344` <br> `16x9` → `1344x768` <br> `21x9` → `1536x672` |
| `compatibility_mode` | 布尔 | **兼容模式**:启用后通过chat端点实现图像生成,兼容OpenRouter等第三方服务 |
| `stream` | 布尔 | 启用流式模式 |
| `debug` | 布尔 | **启用详细调试日志** |
+2 -1
View File
@@ -11,6 +11,7 @@ A modern, unified ComfyUI plugin for OpenAI-compatible APIs with enhanced debugg
I wrote this plugin mainly to simplify my own workflow, allowing access to LLM or image generation through standardized OpenAI interfaces. Recently, I've been mainly using this node to play with Gemini-2.5-Flash-Image generation (haven't tested the official Gemini interface, I'm connecting to an OpenAI-compatible endpoint), and the results are quite good.
## Recent Updates
- 2026-9-7 **Aspect Ratio Labels**: All size presets in the image node now include visual aspect ratio annotations, e.g. `1254x1254(1:1,1K)`, `1024x1536(2:3,1K)`, `1672x941(16:9,1K)`.
- 2025-11-21 v1.0.7 **New Models & Aspect Ratio Support**: Added support for models like `nano-banana` and `nano-banana-pro`, and allows using aspect ratios for size in non-compatibility mode.
![](thumb/flexai-image-nanobananapro.jpg)
- 2025-9-13 v1.0.6 The image node now supports JPG type for image URI content to be compatible with some seedream4 API requests.
@@ -112,7 +113,7 @@ OPENAI_API_BASE_custom=https://your-api.example.com/v1
| `model` | String | Model name (e.g., `dall-e-3`, `dall-e-2`) |
| `prompt` | String | Generation/editing prompt |
| `image_1-4` | Image | Optional images (edit mode if any provided) |
| `size` | String | Output size (e.g., `1024x1024`) <br><br> **Aspect Ratio Support**: For specific models like `nano-banana` and `nano-banana-pro` in **non-compatibility mode**, this field accepts aspect ratios. Supported ratios and their corresponding resolutions are:<br> `1x1` → `1024x1024` <br> `2x3` → `832x1248` <br> `3x2` → `1248x832` <br> `3x4` → `864x1184` <br> `4x3` → `1184x864` <br> `4x5` → `896x1152` <br> `5x4` → `1152x896` <br> `9x16` → `768x1344` <br> `16x9` → `1344x768` <br> `21x9` → `1536x672` |
| `size` | Choice/String | Output size preset with aspect ratio label (e.g. `1254x1254(1:1,1K)`, `1024x1536(2:3,1K)`, `1672x941(16:9,1K)`). The dropdown offers all common ratios across 1K/2K/4K tiers. Use `custom_size` for free-form input. <br><br> **Aspect Ratio Support**: For specific models like `nano-banana` and `nano-banana-pro` in **non-compatibility mode**, this field accepts aspect ratios. Supported ratios and their corresponding resolutions are:<br> `1x1` → `1024x1024` <br> `2x3` → `832x1248` <br> `3x2` → `1248x832` <br> `3x4` → `864x1184` <br> `4x3` → `1184x864` <br> `4x5` → `896x1152` <br> `5x4` → `1152x896` <br> `9x16` → `768x1344` <br> `16x9` → `1344x768` <br> `21x9` → `1536x672` |
| `compatibility_mode` | Boolean | **Compatibility Mode**: Enable chat endpoint for image generation, compatible with OpenRouter and other third-party services |
| `stream` | Boolean | Enable streaming mode |
| `debug` | Boolean | **Enable detailed debug logging** |
+60 -32
View File
@@ -1,13 +1,15 @@
"""Plugin entry for Comfyui_flexai.
"""Plugin entry for Comfyui-flexai.
Provides the FlexAI series of nodes.
Node Naming Convention:
- Key: flexai:openai_image / flexai:openai_text
- Display Name: OpenAI Image (FlexAI) / OpenAI Text (FlexAI)
Category: flexai
Provides:
- V3 image node (FlexAI_OpenAIImageNode, registered via comfy_entrypoint)
- V1 text node (flexai:openai_text, injected on_load from the extension)
IMPORTANT: ComfyUI's boot code short-circuits — once __init__.py has
NODE_CLASS_MAPPINGS the comfy_entrypoint branch is *never* reached.
Therefore the image node goes through comfy_entrypoint and the text
node is injected manually in on_load().
"""
__version__ = "1.0.6"
__version__ = "1.0.8"
import os
import sys
@@ -17,11 +19,8 @@ current_dir = os.path.dirname(os.path.abspath(__file__))
if current_dir not in sys.path:
sys.path.insert(0, current_dir)
def load_node_module(module_rel_path: str):
"""Load a node module dynamically from nodes/ subpath.
module_rel_path: e.g. "flexai/genimage" (without .py)
"""
def load_node_module(module_rel_path: str):
module_fs_path = os.path.join(current_dir, 'nodes', f'{module_rel_path}.py')
module_name = module_rel_path.replace('/', '.')
spec = importlib.util.spec_from_file_location(module_name, module_fs_path)
@@ -29,27 +28,56 @@ def load_node_module(module_rel_path: str):
spec.loader.exec_module(module)
return module
# Load FlexAI nodes
openai_image_module = load_node_module('flexai/openai_image')
openai_text_module = load_node_module('flexai/openai_text')
OpenAIImageNode = openai_image_module.OpenAIImageNode
OpenAITextNode = openai_text_module.OpenAITextNode
# Load FlexAI node modules.
image_module = load_node_module('flexai/openai_image')
text_module = load_node_module('flexai/openai_text')
OpenAITextNode = text_module.OpenAITextNode
NODE_CLASS_MAPPINGS = {
"flexai:openai_image": OpenAIImageNode,
"flexai:openai_text": OpenAITextNode,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"flexai:openai_image": "OpenAI Image (FlexAI)",
"flexai:openai_text": "OpenAI Text (FlexAI)",
}
# ---- V3 registration via comfy_entrypoint ----
try:
from comfy_api.latest import ComfyExtension
_V3_OK = True
except ImportError:
ComfyExtension = None
_V3_OK = False
__all__ = [
'__version__',
'NODE_CLASS_MAPPINGS',
'NODE_DISPLAY_NAME_MAPPINGS',
'OpenAIImageNode',
'OpenAITextNode'
]
if _V3_OK:
class FlexAIExtension(ComfyExtension):
async def on_load(self):
"""Inject the V1 text node so existing workflows keep working."""
try:
import nodes
nodes.NODE_CLASS_MAPPINGS["flexai:openai_text"] = OpenAITextNode
nodes.NODE_DISPLAY_NAME_MAPPINGS["flexai:openai_text"] = "OpenAI Text (FlexAI)"
except Exception:
pass # best-effort; the text node is non-critical
async def get_node_list(self):
return [image_module.OpenAIImageNode]
async def comfy_entrypoint():
if not _V3_OK:
return None
return FlexAIExtension()
# ---- Fallback for older ComfyUI without comfy_api ----
# The image node module already defines a V1 fallback class, so adding
# NODE_CLASS_MAPPINGS here works for both.
if not _V3_OK:
NODE_CLASS_MAPPINGS = {
"flexai:openai_image": image_module.OpenAIImageNode,
"flexai:openai_text": OpenAITextNode,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"flexai:openai_image": "OpenAI Image (FlexAI)",
"flexai:openai_text": "OpenAI Text (FlexAI)",
}
__all__ = ['__version__', 'comfy_entrypoint']
+615 -263
View File
@@ -1,19 +1,26 @@
"""OpenAIImageNode - A unified node for image generation and editing (ComfyUI FlexAI Plugin v1.0.4).
"""OpenAIImageNode - A unified node for image generation and editing (ComfyUI FlexAI Plugin).
Features:
- Dual-mode operation: Generation (images.generate) and Editing (images.edit).
- Smart detection: Automatically selects the mode based on image input.
- Edit mode: Supports editing 1-4 images (leveraging multi-image capabilities).
- Generation mode: Generates images from text prompts.
- Error handling: Provides a user-friendly error image on failure instead of crashing.
- Uses the modern OpenAI Python SDK (>=1.0).
- Supports both base64 and URL response formats.
- Enhanced debugging: Detailed API request/response logging and error analysis.
- Dual-mode operation: Generation (images.generate) and Editing (images.edit).
- Smart detection: Automatically selects the mode based on image input.
- Edit mode: Supports editing 1-4 images (leveraging multi-image capabilities).
- Generation mode: Generates images from text prompts.
- Error handling: Provides a user-friendly error image on failure instead of crashing.
- Uses the modern OpenAI Python SDK (>=1.0).
- Supports both base64 and URL response formats.
- Enhanced debugging: Detailed API request/response logging and error analysis.
- V3 schema (io.Combo): editable size dropdown. For nano-banana / gemini-*-image-*
the selected preset is translated into Gemini imageConfig (imageSize + aspectRatio)
and passed via extra_body, so 2K/4K actually takes effect instead of silently
falling back to 1K.
- Falls back to the legacy INPUT_TYPES-based class if ComfyUI doesn't expose
`comfy_api.latest.io`.
"""
from __future__ import annotations
import os
import sys
import base64
import re
import requests
import importlib.util
from io import BytesIO
@@ -24,21 +31,21 @@ from dotenv import load_dotenv
import provider_config
# -- Dynamically load utility modules --
# To avoid import issues in ComfyUI, load directly from file path
_PLUGIN_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
_UTILS_DIR = os.path.join(_PLUGIN_ROOT, 'nodes', 'utils')
def _load_utils_module(name):
spec = importlib.util.spec_from_file_location(name, os.path.join(_UTILS_DIR, f'{name}.py'))
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
_images_module = _load_utils_module('images')
_openai_standard_module = _load_utils_module('openai_standard')
_model_manager_module = _load_utils_module('model_manager')
# Import functions from utility modules
pil_to_tensor = _images_module.pil_to_tensor
tensor_to_pil = _images_module.tensor_to_pil
pil_to_base64 = _images_module.pil_to_base64
@@ -50,18 +57,17 @@ log_api_interaction = _openai_standard_module.log_api_interaction
get_models = _model_manager_module.get_models
add_model = _model_manager_module.add_model
# Load environment variables
load_dotenv(os.path.join(_PLUGIN_ROOT, '.env'), override=True)
_MODEL_KEY = "flexai_image_models"
def download_image_from_url(url: str, timeout: int = 30, debug: bool = False) -> Image.Image:
"""Downloads an image from a URL and returns a PIL Image object."""
if debug:
debug_log(f"Downloading image from URL: {url[:100]}...")
try:
headers = {'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'}
# Add verify=False to try to resolve SSLError
if debug:
debug_log("Skipping SSL verification for image download.")
response = requests.get(url, headers=headers, timeout=timeout, verify=False)
@@ -77,290 +83,636 @@ def download_image_from_url(url: str, timeout: int = 30, debug: bool = False) ->
raise ValueError(error_msg) from e
class OpenAIImageNode:
@classmethod
def INPUT_TYPES(cls):
provider_names = provider_config.get_provider_display_names() or ["default"]
models = get_models(_MODEL_KEY)
return {
"required": {
"provider": (provider_names, {"default": provider_names[0]}),
"model": (models, {"default": models[0] if models else "dall-e-3"}),
},
"optional": {
"custom_model": ("STRING", {"default": "", "placeholder": "Enter new model (overrides selection and saves automatically)"}),
"prompt": ("STRING", {"multiline": True, "default": "A cute cat in watercolor."}),
"image_1": ("IMAGE",), "image_2": ("IMAGE",), "image_3": ("IMAGE",), "image_4": ("IMAGE",),
"size": ("STRING", {"default": "", "placeholder": "Optional, e.g., 1024x1024"}),
"compatibility_mode": ("BOOLEAN", {"default": False, "tooltip": "Enable compatibility mode for services like OpenRouter via chat endpoints."}),
"streaming": ("BOOLEAN", {"default": False, "tooltip": "Enable streaming for compatibility mode."}),
"debug": ("BOOLEAN", {"default": False}),
}
}
# ---- v3 schema import (deferred so module still loads on older ComfyUI) ----
try:
from comfy_api.latest import io as _v3_io
_V3_AVAILABLE = True
except ImportError:
_v3_io = None
_V3_AVAILABLE = False
RETURN_TYPES = ("IMAGE",)
FUNCTION = "execute"
CATEGORY = "flexai"
def execute(self, provider, model, prompt, size="", compatibility_mode=False, streaming=False, debug=False, custom_model="", **kwargs):
"""Main execution function, dispatches to generation or editing based on mode."""
try:
# Handle empty size input
final_size = size.strip()
if not final_size and not compatibility_mode:
final_size = "1024x1024" # Default for native mode
# Models that need imageConfig (Gemini native protocol) instead of OpenAI size.
_IMAGE_CONFIG_MODELS = ("nano-banana", "gemini-3-pro-image", "gemini-2.5-flash-image")
# Determine the final model name to use
final_model = custom_model.strip() if custom_model and custom_model.strip() else model
if custom_model.strip():
add_model(custom_model.strip(), _MODEL_KEY) # If a custom model is used, save it
client = self._setup_client(provider)
images = [img for img in [kwargs.get(f"image_{i}") for i in range(1, 5)] if img is not None]
# Size presets for the dropdown.
# Each preset is "<WxH>(<aspect-ratio>,<resolution-level>)".
# The "(...)" suffix is purely descriptive — it's stripped before parsing
# or forwarding to the API.
#
# 1K sizes are the 11 hardcoded resolutions recognized by the upstream
# billing expression (size 1K tier). 2K = 1K * 2, 4K = 1K * 4 per side.
_SIZE_PRESETS = [
# 1:1
"1254x1254(1:1,1K)",
"2508x2508(1:1,2K)",
"5016x5016(1:1,4K)",
# 2:3 / 3:2
"1024x1536(2:3,1K)",
"2048x3072(2:3,2K)",
"4096x6144(2:3,4K)",
"1536x1024(3:2,1K)",
"3072x2048(3:2,2K)",
"6144x4096(3:2,4K)",
# 3:4 / 4:3
"1086x1448(3:4,1K)",
"2172x2896(3:4,2K)",
"4344x5792(3:4,4K)",
"1448x1086(4:3,1K)",
"2896x2172(4:3,2K)",
"5792x4344(4:3,4K)",
# 4:5 / 5:4
"1122x1402(4:5,1K)",
"2244x2804(4:5,2K)",
"4488x5608(4:5,4K)",
"1402x1122(5:4,1K)",
"2804x2244(5:4,2K)",
"5608x4488(5:4,4K)",
# 16:9 / 9:16
"1672x941(16:9,1K)",
"3344x1882(16:9,2K)",
"6688x3764(16:9,4K)",
"941x1672(9:16,1K)",
"1882x3344(9:16,2K)",
"3764x6688(9:16,4K)",
# 21:9 / 9:21
"1915x821(21:9,1K)",
"3830x1642(21:9,2K)",
"7660x3284(21:9,4K)",
"821x1915(9:21,1K)",
"1642x3830(9:21,2K)",
"3284x7660(9:21,4K)",
]
if compatibility_mode:
if debug: debug_log("Running in Compatibility Mode (Chat).")
pil_images = self._run_chat_mode(client, final_model, prompt, images, final_size, streaming, debug)
else:
if debug: debug_log("Running in Native Mode (Image API).")
pil_images = self._run_native_mode(client, final_model, prompt, images, final_size, debug)
return (pil_to_tensor(pil_images),)
except Exception as e:
if debug:
import traceback
debug_log(f"An error occurred: {e}\n{traceback.format_exc()}")
return (self._create_error_image(str(e)),)
def _needs_image_config(model: str) -> bool:
m = (model or "").lower()
return any(tok in m for tok in _IMAGE_CONFIG_MODELS)
def _setup_client(self, provider_name: str):
"""Configures and returns the API client."""
prov = provider_config.get_provider_by_name(provider_name)
if not prov:
raise ValueError(f"Provider '{provider_name}' not found.")
if not prov.api_key or prov.api_key.startswith("your_key"):
raise ValueError("API key is not configured.")
return ensure_client(prov.api_key, prov.base_url)
# --- Native Mode ---
def _run_native_mode(self, client, model, prompt, images, size, debug):
"""Uses the native OpenAI images.generate or images.edit endpoints."""
if images:
# Edit mode
if debug: debug_log(f"Native mode: editing {len(images)} image(s).")
image_files = self._preprocess_images_for_edit(images, debug)
try:
params = {
"model": model,
"prompt": prompt,
"size": size,
"n": 1,
"response_format": "b64_json",
"image_bytes": image_files[0].getbuffer().nbytes if image_files else 0
}
log_api_interaction("Native Image Edit Request", params, debug)
def _parse_size_to_image_config(size: str):
"""Map a `WxH` size string into {imageSize, aspectRatio}.
# Note: The OpenAI SDK's images.edit 'image' parameter accepts a single file, not a list.
# To support multi-image editing, the custom chat mode must be used.
if len(image_files) > 1 and debug:
debug_log("Warning: Native edit mode only uses the first image.")
response = client.images.edit(model=model, image=image_files[0], prompt=prompt, size=size, response_format="b64_json", n=1)
finally:
for f in image_files:
f.close()
Returns None if size cannot be parsed.
Resolution level comes from the user's annotation when present
(e.g. "1024x1024 (2K)" -> 2K, ignoring the actual pixel count). This
matches the upstream billing behaviour where the size string is matched
against a whitelist before falling back to a pixel-based bucket.
For free-form input without an annotation, resolution is bucketed by
total pixel count to match the upstream billing tiers:
<= 1048576 px (= 1024x1024) -> 1K
<= 4194304 px (= 2048x2048) -> 2K
> 4194304 px -> 4K
"""
if not size:
return None
raw = size.strip()
# Extract trailing "(<annotation>)" if present.
# Annotation format can be "<level>" (legacy, e.g. "1K") or
# "<ratio>,<level>" (new, e.g. "1:1,1K").
ann_match = re.search(r"\(([^)]+)\)\s*$", raw)
annotation_raw = ann_match.group(1).strip() if ann_match else None
# Determine resolution level from annotation.
# New format: "(1:1,1K)" → level = "1K"
# Legacy format: "(1K)" → level = "1K"
resolution_level = None
if annotation_raw:
parts = annotation_raw.split(",")
resolution_level = parts[-1].strip().upper()
# Strip the annotation for further parsing.
s = re.sub(r"\s*\([^)]*\)\s*$", "", raw).lower().replace(" ", "")
# Pure K-spec input ("1K", "2K", "4K").
if s in {"512", "1k", "2k", "4k"}:
return {"imageSize": s.upper() if len(s) > 1 else s, "aspectRatio": "1:1"}
m = re.match(r"^(\d+)[x\*:](\d+)$", s)
if not m:
return None
w, h = int(m.group(1)), int(m.group(2))
if w <= 0 or h <= 0:
return None
# Honour the user's annotation if it looks like a resolution level.
if resolution_level in {"1K", "2K", "4K"}:
image_size = resolution_level
else:
# Fall back to pixel-based bucketing.
pixels = w * h
if pixels <= 1048576:
image_size = "1K"
elif pixels <= 4194304:
image_size = "2K"
else:
# Generation mode
image_size = "4K"
ratio = w / h
ratios = {
"1:1": 1.0,
"16:9": 16 / 9,
"9:16": 9 / 16,
"4:3": 4 / 3,
"3:4": 3 / 4,
"3:2": 3 / 2,
"2:3": 2 / 3,
"4:5": 4 / 5,
"5:4": 5 / 4,
"21:9": 21 / 9,
}
aspect_ratio = min(ratios.keys(), key=lambda k: abs(ratios[k] - ratio))
return {"imageSize": image_size, "aspectRatio": aspect_ratio}
def _provider_names():
return provider_config.get_provider_display_names() or ["default"]
def _model_names():
return get_models(_MODEL_KEY)
# ---- Module-level helpers used by both the V3 and legacy execute paths ----
def _preprocess_images_for_edit(images, debug):
byte_streams = []
for i, tensor in enumerate(images):
if debug:
debug_log(f"Preprocessing image {i+1}/{len(images)} for editing.")
pil_img = tensor_to_pil(tensor[0] if tensor.ndim == 4 else tensor)
if pil_img.mode != 'RGBA':
pil_img = pil_img.convert('RGBA')
byte_stream = BytesIO()
pil_img.save(byte_stream, format='PNG')
byte_stream.seek(0)
byte_streams.append(byte_stream)
return byte_streams
def _process_image_api_response(response, debug):
try:
response_dict = response.model_dump() if hasattr(response, "model_dump") else vars(response)
log_api_interaction("Full Native Image API Response", response_dict, debug)
except Exception as e:
if debug:
debug_log(f"Could not serialize native response for debug log: {e}")
if not response.data:
raise ValueError("API response contained no data.")
image_data_list = []
for item in response.data:
image_data = item.b64_json or item.url
if image_data:
image_data_list.append(image_data)
if debug:
source = "b64_json" if item.b64_json else "url"
debug_log(f"Received image data from '{source}'.")
else:
if debug:
debug_log("Skipping item with no b64_json or url.")
if not image_data_list:
raise ValueError("API response did not contain any valid b64_json or a URL.")
return _data_list_to_pils(image_data_list, debug)
def _run_native_mode(client, model, prompt, images, size, debug):
image_config = None
if _needs_image_config(model):
image_config = _parse_size_to_image_config(size) if size else None
if image_config and debug:
debug_log(f"imageConfig for {model}: {image_config} (parsed from size='{size}')")
elif size and debug:
debug_log(f"Could not parse size='{size}' into imageConfig; falling back to model default.")
if images:
if debug:
debug_log(f"Native mode: editing {len(images)} image(s).")
image_files = _preprocess_images_for_edit(images, debug)
try:
params = {
"model": model,
"prompt": prompt,
"size": size,
"n": 1,
"response_format": "b64_json"
"response_format": "b64_json",
"image_bytes": image_files[0].getbuffer().nbytes if image_files else 0,
}
log_api_interaction("Native Image Generate Request", params, debug)
response = client.images.generate(model=model, prompt=prompt, size=size, response_format="b64_json", n=1)
return self._process_image_api_response(response, debug)
if image_config:
params["imageConfig"] = image_config
log_api_interaction("Native Image Edit Request", params, debug)
def _preprocess_images_for_edit(self, images: List, debug: bool) -> List[BytesIO]:
"""Converts input Tensors to a list of PNG byte streams for the API call."""
byte_streams = []
for i, tensor in enumerate(images):
if debug: debug_log(f"Preprocessing image {i+1}/{len(images)} for editing.")
pil_img = tensor_to_pil(tensor[0] if tensor.ndim == 4 else tensor)
if pil_img.mode != 'RGBA':
pil_img = pil_img.convert('RGBA')
byte_stream = BytesIO()
pil_img.save(byte_stream, format='PNG')
byte_stream.seek(0)
byte_streams.append(byte_stream)
return byte_streams
if len(image_files) > 1 and debug:
debug_log("Warning: Native edit mode only uses the first image.")
def _process_image_api_response(self, response: Any, debug: bool) -> List[Image.Image]:
"""Processes the response from the images.generate/edit API, with multi-image support."""
edit_kwargs = dict(
model=model,
image=image_files[0],
prompt=prompt,
size=size,
response_format="b64_json",
n=1,
)
if image_config:
edit_kwargs["extra_body"] = {"imageConfig": image_config}
response = client.images.edit(**edit_kwargs)
finally:
for f in image_files:
f.close()
else:
params = {
"model": model,
"prompt": prompt,
"size": size,
"n": 1,
"response_format": "b64_json",
}
if image_config:
params["imageConfig"] = image_config
log_api_interaction("Native Image Generate Request", params, debug)
gen_kwargs = dict(
model=model,
prompt=prompt,
size=size,
response_format="b64_json",
n=1,
)
if image_config:
gen_kwargs["extra_body"] = {"imageConfig": image_config}
response = client.images.generate(**gen_kwargs)
return _process_image_api_response(response, debug)
def _run_chat_mode(client, model, prompt, images, size, streaming, debug):
prompt_text = prompt or ""
if size:
prompt_text = f"{prompt_text}\n\nImage size: {size}"
content = [{"type": "text", "text": prompt_text}]
for i, tensor in enumerate(images):
if debug:
debug_log(f"Encoding image {i+1}/{len(images)} for chat.")
pil_img = tensor_to_pil(tensor[0] if tensor.ndim == 4 else tensor)
pil_img.thumbnail((1024, 1024), Image.Resampling.LANCZOS)
base64_url = pil_to_base64(pil_img)
content.append({"type": "image_url", "image_url": {"url": base64_url}})
messages = [{"role": "user", "content": content}]
response_data = chat_complete(
client,
model=model,
messages=messages,
stream=streaming,
temperature=0.7,
top_p=1.0,
seed=None,
include_usage=True,
max_tokens=4000,
debug=debug,
)
log_api_interaction("Full Chat Mode API Response", response_data, debug)
image_data_list = _extract_image_from_chat_response(response_data, debug)
if not image_data_list:
raise ValueError("No image data found in chat response.")
return _data_list_to_pils(image_data_list, debug)
def _extract_image_from_chat_response(response, debug):
if not response:
return []
image_data_list = []
if "images" in response and isinstance(response["images"], list):
for image_item in response["images"]:
url = None
if isinstance(image_item, dict):
url = image_item.get("image_url", {}).get("url") or image_item.get("url")
elif isinstance(image_item, str):
url = image_item
if url:
if "base64," in url:
image_data_list.append(url.split("base64,", 1)[1])
else:
image_data_list.append(url)
content = response.get("content", "")
if not content:
try:
response_dict = response.model_dump() if hasattr(response, "model_dump") else vars(response)
log_api_interaction("Full Native Image API Response", response_dict, debug)
content = response.get("choices", [{}])[0].get("message", {}).get("content", "")
except (IndexError, KeyError):
pass
if content:
url_pattern = r'!\[.*?\]\((https?://[^\s"\'\)]+)\)|(https?://[^\s"\'\)]+)'
found_urls = re.findall(url_pattern, content)
for url_tuple in found_urls:
url = url_tuple[0] or url_tuple[1]
if url:
image_data_list.append(url)
b64_pattern = r'data:image/[^;]+;base64,([A-Za-z0-9+/=]+)'
found_b64 = re.findall(b64_pattern, content)
image_data_list.extend(found_b64)
unique_image_data = list(dict.fromkeys(image_data_list))
if debug:
debug_log(f"Found {len(unique_image_data)} unique image data items in chat response.")
return unique_image_data
def _data_list_to_pils(data_list, debug):
pil_images = []
for i, data in enumerate(data_list):
try:
if debug:
debug_log(f"Processing image data {i+1}/{len(data_list)}...")
if data.startswith("http"):
if debug:
debug_log("Data is a URL, downloading...")
img = download_image_from_url(data, debug=debug)
else:
if debug:
debug_log("Data is base64, decoding...")
img = Image.open(BytesIO(base64.b64decode(data)))
pil_images.append(img)
except Exception as e:
if debug:
debug_log(f"Could not serialize native response for debug log: {e}")
debug_log(f"Failed to process image data item {i+1}: {e}")
continue
return pil_images
if not response.data:
raise ValueError("API response contained no data.")
image_data_list = []
for item in response.data:
image_data = item.b64_json or item.url
if image_data:
image_data_list.append(image_data)
if debug:
source = "b64_json" if item.b64_json else "url"
debug_log(f"Received image data from '{source}'.")
else:
if debug:
debug_log("Skipping item with no b64_json or url.")
if not image_data_list:
raise ValueError("API response did not contain any valid b64_json or a URL.")
return self._data_list_to_pils(image_data_list, debug)
def _create_error_image(error_msg):
img = Image.new('RGB', (512, 512), color='#300000')
draw = ImageDraw.Draw(img)
try:
font_path = "Arial.ttf" if sys.platform == "win32" else "/System/Library/Fonts/Helvetica.ttc"
font = ImageFont.truetype(font_path, 18)
except IOError:
font = ImageFont.load_default()
lines = []
words = error_msg.split()
line = ""
for word in words:
if len(line + " " + word) < 50:
line += " " + word
else:
lines.append(line.strip())
line = word
lines.append(line.strip())
draw.text((10, 10), "FlexAI Node Error:", fill='#ff4444', font=font)
for i, line in enumerate(lines[:20]):
draw.text((10, 40 + i * 20), line, fill='white', font=font)
return pil_to_tensor(img)
# --- Compatibility (Chat) Mode ---
def _run_chat_mode(self, client, model, prompt, images, size, streaming, debug) -> List[Image.Image]:
"""Generates or edits an image using the chat.completions endpoint."""
messages = self._build_chat_messages(prompt, images, size, debug)
response_data = chat_complete(client, model=model, messages=messages, stream=streaming, temperature=0.7, top_p=1.0, seed=None, include_usage=True, max_tokens=4000, debug=debug)
log_api_interaction("Full Chat Mode API Response", response_data, debug)
image_data_list = self._extract_image_from_chat_response(response_data, debug)
if not image_data_list:
raise ValueError("No image data found in chat response.")
return self._data_list_to_pils(image_data_list, debug)
# ---- V3 schema node (preferred when available) ----
def _build_chat_messages(self, prompt: str, images: List, size: str, debug: bool) -> List[Dict]:
"""Builds the message body for Chat mode."""
prompt_text = prompt
if size:
prompt_text = f"{prompt}\n\nImage size: {size}"
content = [{"type": "text", "text": prompt_text}]
for i, tensor in enumerate(images):
if debug: debug_log(f"Encoding image {i+1}/{len(images)} for chat.")
pil_img = tensor_to_pil(tensor[0] if tensor.ndim == 4 else tensor)
# Resize image to reduce token consumption
pil_img.thumbnail((1024, 1024), Image.Resampling.LANCZOS)
base64_url = pil_to_base64(pil_img)
content.append({"type": "image_url", "image_url": {"url": base64_url}})
return [{"role": "user", "content": content}]
if _V3_AVAILABLE:
def _extract_image_from_chat_response(self, response: Dict, debug: bool) -> List[str]:
"""Robustly extracts all image data (URL or Base64) from the Chat API response."""
if not response: return []
image_data_list = []
class OpenAIImageNode(_v3_io.ComfyNode):
"""OpenAI-compatible image generation/editing (V3 schema).
# First, try to extract from the `images` field (e.g., from streaming aggregation)
if "images" in response and isinstance(response["images"], list):
for image_item in response["images"]:
url = None
if isinstance(image_item, dict):
url = image_item.get("image_url", {}).get("url") or image_item.get("url")
elif isinstance(image_item, str):
url = image_item
if url:
if "base64," in url:
image_data_list.append(url.split("base64,", 1)[1])
else:
image_data_list.append(url)
Same node ID and display name as before (`flexai:openai_image` /
"OpenAI Image (FlexAI)"), so existing workflows stay connected.
Uses io.Combo so `size` renders as an editable dropdown.
"""
# Second, try to extract from the aggregated content field or original response structure
content = response.get("content", "")
if not content:
@classmethod
def define_schema(cls):
providers = _provider_names()
models = _model_names()
return _v3_io.Schema(
node_id="flexai:openai_image",
display_name="OpenAI Image (FlexAI)",
category="flexai",
description=(
"OpenAI-compatible image generation/editing. Supports nano-banana, "
"gpt-image-1, dall-e-3, etc. For nano-banana / gemini-*-image-* the "
"selected size is translated into Gemini's imageConfig "
"(imageSize + aspectRatio) so 2K/4K actually takes effect."
),
inputs=[
_v3_io.Combo.Input(
"provider",
options=providers,
default=providers[0],
tooltip="API provider (configured in .env).",
),
_v3_io.Combo.Input(
"model",
options=models,
default=models[0] if models else "dall-e-3",
tooltip="Image model.",
),
_v3_io.String.Input(
"custom_model",
default="",
multiline=False,
optional=True,
tooltip="Override the model and save it to the dropdown.",
),
_v3_io.String.Input(
"prompt",
default="A cute cat in watercolor.",
multiline=True,
optional=True,
),
_v3_io.Image.Input("image_1", optional=True),
_v3_io.Image.Input("image_2", optional=True),
_v3_io.Image.Input("image_3", optional=True),
_v3_io.Image.Input("image_4", optional=True),
_v3_io.Combo.Input(
"size",
options=_SIZE_PRESETS,
default="1254x1254(1:1,1K)",
optional=True,
tooltip=(
"Output size preset including aspect ratio. "
"Format: '<WxH>(<ratio>,<level>)', e.g. '1254x1254(1:1,1K)'. "
"For nano-banana / gemini-*-image-*, parsed into imageConfig "
"(imageSize + aspectRatio) automatically."
),
),
_v3_io.String.Input(
"custom_size",
default="",
multiline=False,
optional=True,
tooltip=(
"Free-form size override (takes priority over 'size'). "
"Examples: '3072x2048', '2048x2048', '4K', '16:9'. "
"Leave empty to use the 'size' preset."
),
),
_v3_io.Boolean.Input(
"compatibility_mode",
default=False,
optional=True,
tooltip=(
"Use the chat completions endpoint for OpenRouter-like "
"providers. Auto-disabled for nano-banana / "
"gemini-*-image-* (chat path can't carry imageConfig)."
),
),
_v3_io.Boolean.Input(
"streaming",
default=False,
optional=True,
tooltip="Enable streaming for compatibility mode.",
),
_v3_io.Boolean.Input("debug", default=False, optional=True),
],
outputs=[_v3_io.Image.Output()],
)
@classmethod
def execute(
cls,
provider: str,
model: str,
custom_model: str = "",
prompt: str = "",
image_1=None,
image_2=None,
image_3=None,
image_4=None,
size: str = "1254x1254(1:1,1K)",
custom_size: str = "",
compatibility_mode: bool = False,
streaming: bool = False,
debug: bool = False,
):
try:
content = response.get("choices", [{}])[0].get("message", {}).get("content", "")
except (IndexError, KeyError):
pass
# custom_size (free-form) takes priority over the size preset.
# Strip the "(...)" annotation from the preset for API forwarding.
preset = (size or "").strip()
preset_clean = re.sub(r"\s*\([^)]*\)\s*$", "", preset)
custom = (custom_size or "").strip()
final_size = custom or preset_clean
if not final_size and not compatibility_mode:
final_size = "1024x1024"
if content:
import re
# Match all Markdown images `![alt](url)` or direct URLs
url_pattern = r'!\[.*?\]\((https?://[^\s"\'\)]+)\)|(https?://[^\s"\'\)]+)'
found_urls = re.findall(url_pattern, content)
for url_tuple in found_urls:
url = url_tuple[0] or url_tuple[1]
if url:
image_data_list.append(url)
# Match all Base64 data URIs
b64_pattern = r'data:image/[^;]+;base64,([A-Za-z0-9+/=]+)'
found_b64 = re.findall(b64_pattern, content)
image_data_list.extend(found_b64)
final_model = custom_model.strip() if custom_model and custom_model.strip() else model
if custom_model.strip():
add_model(custom_model.strip(), _MODEL_KEY)
# Deduplicate while preserving order
unique_image_data = list(dict.fromkeys(image_data_list))
if debug:
debug_log(f"Found {len(unique_image_data)} unique image data items in chat response.")
return unique_image_data
prov = provider_config.get_provider_by_name(provider)
if not prov:
raise ValueError(f"Provider '{provider}' not found.")
if not prov.api_key or prov.api_key.startswith("your_key"):
raise ValueError("API key is not configured.")
client = ensure_client(prov.api_key, prov.base_url)
# --- Utility Methods ---
def _data_list_to_pils(self, data_list: List[str], debug: bool) -> List[Image.Image]:
"""Converts a list of URLs or Base64 data into a list of PIL images."""
pil_images = []
for i, data in enumerate(data_list):
try:
if debug: debug_log(f"Processing image data {i+1}/{len(data_list)}...")
if data.startswith("http"):
if debug: debug_log("Data is a URL, downloading...")
img = download_image_from_url(data, debug=debug)
images = [img for img in [image_1, image_2, image_3, image_4] if img is not None]
# nano-banana / gemini-*-image-* must use the native image API
# so imageConfig (imageSize + aspectRatio) takes effect.
if compatibility_mode and not _needs_image_config(final_model):
if debug:
debug_log("Running in Compatibility Mode (Chat).")
pil_images = _run_chat_mode(client, final_model, prompt, images, final_size, streaming, debug)
else:
if debug: debug_log("Data is base64, decoding...")
img = Image.open(BytesIO(base64.b64decode(data)))
pil_images.append(img)
if compatibility_mode and debug:
debug_log(f"Model {final_model} uses imageConfig; routing to native mode (chat path would silently drop size).")
if debug:
debug_log("Running in Native Mode (Image API).")
pil_images = _run_native_mode(client, final_model, prompt, images, final_size, debug)
return _v3_io.NodeOutput(pil_to_tensor(pil_images))
except Exception as e:
if debug:
debug_log(f"Failed to process image data item {i+1}: {e}")
# Log the data snippet that caused the failure, but avoid logging long base64 strings
if len(data) > 100:
debug_log(f"Truncated problematic data: {data[:100]}...")
else:
debug_log(f"Problematic data: {data}")
# When invalid data is encountered, either skip or log the error
continue
return pil_images
import traceback
debug_log(f"An error occurred: {e}\n{traceback.format_exc()}")
return _v3_io.NodeOutput(_create_error_image(str(e)))
def _create_error_image(self, error_msg: str) -> Image.Image:
"""Creates an image displaying the error message."""
img = Image.new('RGB', (512, 512), color='#300000')
draw = ImageDraw.Draw(img)
try:
# Try to load a common cross-platform font
font_path = "Arial.ttf" if sys.platform == "win32" else "/System/Library/Fonts/Helvetica.ttc"
font = ImageFont.truetype(font_path, 18)
except IOError:
font = ImageFont.load_default()
else:
# Simple text wrapping
lines = []
words = error_msg.split()
line = ""
for word in words:
if len(line + " " + word) < 50:
line += " " + word
else:
lines.append(line.strip())
line = word
lines.append(line.strip())
draw.text((10, 10), "FlexAI Node Error:", fill='#ff4444', font=font)
for i, line in enumerate(lines[:20]): # Limit number of lines
draw.text((10, 40 + i * 20), line, fill='white', font=font)
return pil_to_tensor(img)
class OpenAIImageNode:
"""Legacy V1 node (kept for ComfyUI without comfy_api.latest).
Same node ID and display name as the V3 version, so swapping back
and forth keeps existing workflows connected.
"""
@classmethod
def INPUT_TYPES(cls):
provider_names = _provider_names()
models = _model_names()
return {
"required": {
"provider": (provider_names, {"default": provider_names[0]}),
"model": (models, {"default": models[0] if models else "dall-e-3"}),
},
"optional": {
"custom_model": ("STRING", {"default": "", "placeholder": "Enter new model (overrides selection and saves automatically)"}),
"prompt": ("STRING", {"multiline": True, "default": "A cute cat in watercolor."}),
"image_1": ("IMAGE",), "image_2": ("IMAGE",), "image_3": ("IMAGE",), "image_4": ("IMAGE",),
"size": ("STRING", {
"default": "1254x1254(1:1,1K)",
"multiline": False,
"placeholder": "1254x1254(1:1,1K), 1024x1536(2:3,1K), 2K, 4K, 16:9, ...",
}),
"custom_size": ("STRING", {
"default": "",
"multiline": False,
"placeholder": "Optional free-form override, e.g. 3072x2048, 16:9, 4K",
}),
"compatibility_mode": ("BOOLEAN", {"default": False, "tooltip": "Ignored for nano-banana / gemini-*-image-* (chat path can't carry imageConfig)."}),
"streaming": ("BOOLEAN", {"default": False, "tooltip": "Enable streaming for compatibility mode."}),
"debug": ("BOOLEAN", {"default": False}),
}
}
RETURN_TYPES = ("IMAGE",)
FUNCTION = "execute"
CATEGORY = "flexai"
def execute(self, provider, model, prompt, size="1254x1254(1:1,1K)", custom_size="", compatibility_mode=False, streaming=False, debug=False, custom_model="", **kwargs):
try:
preset = (size or "").strip()
preset_clean = re.sub(r"\s*\([^)]*\)\s*$", "", preset)
custom = (custom_size or "").strip()
final_size = custom or preset_clean
if not final_size and not compatibility_mode:
final_size = "1024x1024"
final_model = custom_model.strip() if custom_model and custom_model.strip() else model
if custom_model.strip():
add_model(custom_model.strip(), _MODEL_KEY)
prov = provider_config.get_provider_by_name(provider)
if not prov:
raise ValueError(f"Provider '{provider}' not found.")
if not prov.api_key or prov.api_key.startswith("your_key"):
raise ValueError("API key is not configured.")
client = ensure_client(prov.api_key, prov.base_url)
images = [img for img in [kwargs.get(f"image_{i}") for i in range(1, 5)] if img is not None]
if compatibility_mode and not _needs_image_config(final_model):
if debug:
debug_log("Running in Compatibility Mode (Chat).")
pil_images = _run_chat_mode(client, final_model, prompt, images, final_size, streaming, debug)
else:
if compatibility_mode and debug:
debug_log(f"Model {final_model} uses imageConfig; routing to native mode (chat path would silently drop size).")
if debug:
debug_log("Running in Native Mode (Image API).")
pil_images = _run_native_mode(client, final_model, prompt, images, final_size, debug)
return (pil_to_tensor(pil_images),)
except Exception as e:
if debug:
import traceback
debug_log(f"An error occurred: {e}\n{traceback.format_exc()}")
return (self._create_error_image_legacy(str(e)),)
def _create_error_image_legacy(self, error_msg):
return _create_error_image(error_msg)
+37
View File
@@ -0,0 +1,37 @@
[project]
name = "flexai"
description = "Use the fewest nodes for the most flexible model calls. A versatile ComfyUI plugin for OpenAI-compatible APIs, featuring multi-purpose nodes for text and image, support for switching between multiple API providers, and auto-saving of custom models. Compatible with new models like nano-banana and seedream4."
version = "1.0.8"
license = {file = "LICENSE"}
# classifiers = [
# # For OS-independent nodes (works on all operating systems)
# "Operating System :: OS Independent",
#
# # OR for OS-specific nodes, specify the supported systems:
# "Operating System :: Microsoft :: Windows", # Windows specific
# "Operating System :: POSIX :: Linux", # Linux specific
# "Operating System :: MacOS", # macOS specific
#
# # GPU Accelerator support. Pick the ones that are supported by your extension.
# "Environment :: GPU :: NVIDIA CUDA", # NVIDIA CUDA support
# "Environment :: GPU :: AMD ROCm", # AMD ROCm support
# "Environment :: GPU :: Intel Arc", # Intel Arc support
# "Environment :: NPU :: Huawei Ascend", # Huawei Ascend support
# "Environment :: GPU :: Apple Metal", # Apple Metal support
# ]
dependencies = ["openai", "Pillow", "python-dotenv", "requests"]
[project.urls]
Repository = "https://github.com/Semonxue/Comfyui-flexai"
# Used by Comfy Registry https://registry.comfy.org
Documentation = "https://github.com/Semonxue/Comfyui-flexai/wiki"
"Bug Tracker" = "https://github.com/Semonxue/Comfyui-flexai/issues"
[tool.comfy]
PublisherId = "semonxue"
DisplayName = "Comfyui-flexai"
Icon = ""
includes = []
# "requires-comfyui" = ">=1.0.0" # ComfyUI version compatibility