- implemented workflow converter and browser
- fixed workflow ui link custom node
This commit is contained in:
-13
@@ -1,15 +1,3 @@
|
||||
"""
|
||||
ComfyUI custom node: WorkflowUI plugin.
|
||||
|
||||
Registers API routes so WorkflowUI (and other clients) can:
|
||||
- Delete files from ComfyUI output/input/temp (POST /delete, POST /workflowui/media/delete)
|
||||
- List and browse media (GET /workflowui/media/list, GET /workflowui/media/tree)
|
||||
- Detect plugin (GET /workflowui/media/capabilities)
|
||||
- Installed modules/versions for run metadata (GET /workflowui/version_info)
|
||||
|
||||
Adds WorkflowUILink node: configurable inputs/outputs for WorkflowUI schema.
|
||||
"""
|
||||
|
||||
from .version_info import __version__
|
||||
from .routes import register_routes
|
||||
from .workflow_ui_link import NODE_CLASS_MAPPINGS as LINK_MAPPINGS
|
||||
@@ -17,7 +5,6 @@ from .workflow_ui_link import NODE_DISPLAY_NAME_MAPPINGS as LINK_DISPLAY_NAMES
|
||||
|
||||
register_routes()
|
||||
|
||||
# Startup banner
|
||||
_banner_text = f" WorkflowUI Plugin v{__version__} "
|
||||
_width = max(len(_banner_text) + 2, 24)
|
||||
_border = "+" + "-" * (_width - 2) + "+"
|
||||
|
||||
@@ -1,36 +1,64 @@
|
||||
"""
|
||||
WorkflowUI plugin: ComfyUI API routes for media access and deletion.
|
||||
|
||||
- POST /delete and POST /workflowui/media/delete — delete a file by filename/subfolder/type
|
||||
(WorkflowUI calls POST /delete with JSON body).
|
||||
- GET /workflowui/media/view — serve a file from output/input/temp; ?preview=webp|jpeg|SIZE for compressed image previews.
|
||||
- GET /workflowui/media/list — list files in output/input/temp (optional subfolder).
|
||||
- GET /workflowui/media/tree — tree of subfolders and files for browsing.
|
||||
- GET /workflowui/media/capabilities — report supported features (delete, list, view).
|
||||
- GET /workflowui/version_info — installed custom node modules and versions (for run metadata).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import json
|
||||
import io
|
||||
import logging
|
||||
import mimetypes
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _load_workflow_converter():
|
||||
try:
|
||||
from .workflow_converter import WorkflowConverter
|
||||
return WorkflowConverter
|
||||
except SyntaxError as e:
|
||||
if "null bytes" not in str(e).lower():
|
||||
raise
|
||||
logger.warning(
|
||||
"WorkflowUIPlugin: workflow_converter.py contains null bytes in source; "
|
||||
"loading with null bytes stripped (check deployment/sync)."
|
||||
)
|
||||
pkg_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
path = os.path.join(pkg_dir, "workflow_converter.py")
|
||||
with open(path, "rb") as f:
|
||||
source = f.read().replace(b"\x00", b"").decode("utf-8", errors="replace")
|
||||
fullname = (__package__ or "WorkflowUIPlugin") + ".workflow_converter"
|
||||
|
||||
class _Loader:
|
||||
def __init__(self, src, pathname):
|
||||
self.src = src
|
||||
self.pathname = pathname
|
||||
def create_module(self, spec):
|
||||
return None
|
||||
def exec_module(self, module):
|
||||
code = compile(self.src, self.pathname, "exec")
|
||||
exec(code, module.__dict__)
|
||||
|
||||
spec = importlib.util.spec_from_loader(fullname, _Loader(source, path), origin=path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[fullname] = module
|
||||
spec.loader.exec_module(module)
|
||||
return module.WorkflowConverter
|
||||
|
||||
MEDIA_TYPES = ("output", "input", "temp")
|
||||
|
||||
PREVIEW_MAX_DIM_DEFAULT = 512
|
||||
PREVIEW_QUALITY_DEFAULT = 85
|
||||
IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp", ".tiff", ".tif"}
|
||||
UPLOAD_IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp", ".gif"}
|
||||
UPLOAD_VIDEO_EXTENSIONS = {".mp4", ".webm", ".mkv", ".mov"}
|
||||
UPLOAD_AUDIO_EXTENSIONS = {".mp3", ".wav", ".ogg", ".flac", ".m4a"}
|
||||
|
||||
|
||||
def _get_media_root(folder_type: str):
|
||||
"""Resolve ComfyUI media root for output/input/temp. Returns None if invalid."""
|
||||
try:
|
||||
import folder_paths
|
||||
out = folder_paths.get_directory_by_type(folder_type)
|
||||
@@ -40,20 +68,14 @@ def _get_media_root(folder_type: str):
|
||||
|
||||
|
||||
def _resolve_media_path(folder_type: str, subfolder: str, filename: str) -> tuple[str | None, str | None]:
|
||||
"""
|
||||
Resolve full_path for a media file. Ensures path is under base (no traversal).
|
||||
Returns (full_path, None) on success; (None, error_message) on validation failure.
|
||||
"""
|
||||
if folder_type not in MEDIA_TYPES:
|
||||
return None, "Invalid type; use output, input, or temp"
|
||||
base = _get_media_root(folder_type)
|
||||
if not base:
|
||||
return None, "Could not resolve media directory"
|
||||
# Normalize subfolder: no leading/trailing slashes, no '..'
|
||||
sub = (subfolder or "").strip().replace("\\", "/").strip("/")
|
||||
if ".." in sub or sub.startswith("/"):
|
||||
return None, "Invalid subfolder"
|
||||
# Filename must be a single segment (no path)
|
||||
name = (filename or "").strip()
|
||||
if not name or "/" in name or "\\" in name or ".." in name:
|
||||
return None, "Invalid filename"
|
||||
@@ -65,7 +87,6 @@ def _resolve_media_path(folder_type: str, subfolder: str, filename: str) -> tupl
|
||||
|
||||
|
||||
def _delete_file(folder_type: str, subfolder: str, filename: str) -> tuple[bool, str, int]:
|
||||
"""Delete one file. Returns (success, message, http_status)."""
|
||||
full, err = _resolve_media_path(folder_type, subfolder, filename)
|
||||
if err:
|
||||
return False, err, 400
|
||||
@@ -92,7 +113,6 @@ def _make_image_preview(
|
||||
max_dim: int,
|
||||
quality: int,
|
||||
) -> tuple[bytes | None, str | None]:
|
||||
"""Return (body, content_type) for a resized/compressed preview, or (None, None) on failure."""
|
||||
try:
|
||||
from PIL import Image
|
||||
except ImportError:
|
||||
@@ -124,7 +144,6 @@ def _make_image_preview(
|
||||
|
||||
|
||||
async def _handle_view(request: web.Request) -> web.Response:
|
||||
"""GET ?filename=...&subfolder=...&type=output&preview=webp|jpeg|SIZE — serve file; optional compressed image preview."""
|
||||
filename = request.query.get("filename", "").strip()
|
||||
subfolder = request.query.get("subfolder", "")
|
||||
folder_type = (request.query.get("type") or "output").strip().lower()
|
||||
@@ -142,7 +161,6 @@ async def _handle_view(request: web.Request) -> web.Response:
|
||||
if not content_type:
|
||||
content_type = "application/octet-stream"
|
||||
|
||||
# Optional preview: only for images; format webp (default) or jpeg, optional max dimension
|
||||
if preview and _is_image_file(filename, content_type):
|
||||
max_dim = PREVIEW_MAX_DIM_DEFAULT
|
||||
quality = PREVIEW_QUALITY_DEFAULT
|
||||
@@ -168,7 +186,6 @@ async def _handle_view(request: web.Request) -> web.Response:
|
||||
|
||||
|
||||
async def _handle_delete(request: web.Request) -> web.Response:
|
||||
"""POST body: JSON { filename, subfolder?, type? }. type defaults to 'output'."""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
@@ -186,8 +203,74 @@ async def _handle_delete(request: web.Request) -> web.Response:
|
||||
return web.json_response({"ok": False, "error": msg}, status=status)
|
||||
|
||||
|
||||
def _content_hash_name(content: bytes, ext: str) -> str:
|
||||
h = hashlib.sha256(content).hexdigest()[:16]
|
||||
return f"{h}{ext}"
|
||||
|
||||
|
||||
async def _handle_upload(request: web.Request) -> web.Response:
|
||||
try:
|
||||
reader = await request.multipart()
|
||||
except Exception as e:
|
||||
logger.debug("WorkflowUIPlugin: upload multipart error: %s", e)
|
||||
return web.json_response({"error": "Invalid multipart body"}, status=400)
|
||||
media_type = (request.query.get("type") or "image").strip().lower()
|
||||
if media_type not in ("image", "video", "audio"):
|
||||
return web.json_response({"error": "type must be image, video, or audio"}, status=400)
|
||||
if media_type == "image":
|
||||
allowed_ext = UPLOAD_IMAGE_EXTENSIONS
|
||||
elif media_type == "video":
|
||||
allowed_ext = UPLOAD_VIDEO_EXTENSIONS
|
||||
else:
|
||||
allowed_ext = UPLOAD_AUDIO_EXTENSIONS
|
||||
part = None
|
||||
while True:
|
||||
try:
|
||||
p = await reader.next()
|
||||
except Exception:
|
||||
p = None
|
||||
if p is None:
|
||||
break
|
||||
name = getattr(p, "name", None) or ""
|
||||
if name in ("file", "image"):
|
||||
part = p
|
||||
break
|
||||
if part is None:
|
||||
return web.json_response({"error": "Missing form field 'file' or 'image'"}, status=400)
|
||||
filename = getattr(part, "filename", None) or "upload"
|
||||
ext = Path(filename).suffix.lower()
|
||||
if ext not in allowed_ext:
|
||||
return web.json_response(
|
||||
{"error": f"Unsupported {media_type} format. Allowed: {', '.join(sorted(allowed_ext))}"},
|
||||
status=400,
|
||||
)
|
||||
try:
|
||||
content = await part.read()
|
||||
except Exception as e:
|
||||
logger.warning("WorkflowUIPlugin: upload read failed: %s", e)
|
||||
return web.json_response({"error": "Failed to read uploaded file"}, status=400)
|
||||
if not content:
|
||||
return web.json_response({"error": "Empty file"}, status=400)
|
||||
base = _get_media_root("input")
|
||||
if not base:
|
||||
return web.json_response({"error": "Could not resolve input directory"}, status=500)
|
||||
safe_name = _content_hash_name(content, ext)
|
||||
full = os.path.join(base, safe_name)
|
||||
try:
|
||||
with open(full, "wb") as f:
|
||||
f.write(content)
|
||||
except OSError as e:
|
||||
logger.warning("WorkflowUIPlugin: upload write failed for %s: %s", full, e)
|
||||
return web.json_response({"error": f"Failed to save file: {e}"}, status=500)
|
||||
return web.json_response({
|
||||
"filename": safe_name,
|
||||
"name": safe_name,
|
||||
"subfolder": "",
|
||||
"type": "input",
|
||||
})
|
||||
|
||||
|
||||
def _list_dir(folder_type: str, subfolder: str) -> tuple[list | None, str | None]:
|
||||
"""List files in type/subfolder. Returns (list of {name, size, mtime}, error)."""
|
||||
if folder_type not in MEDIA_TYPES:
|
||||
return None, "Invalid type"
|
||||
base = _get_media_root(folder_type)
|
||||
@@ -223,7 +306,6 @@ def _list_dir(folder_type: str, subfolder: str) -> tuple[list | None, str | None
|
||||
|
||||
|
||||
async def _handle_list(request: web.Request) -> web.Response:
|
||||
"""GET ?type=output&subfolder= (subfolder optional)."""
|
||||
folder_type = (request.query.get("type") or "output").strip().lower()
|
||||
subfolder = request.query.get("subfolder", "")
|
||||
data, err = _list_dir(folder_type, subfolder)
|
||||
@@ -233,7 +315,6 @@ async def _handle_list(request: web.Request) -> web.Response:
|
||||
|
||||
|
||||
def _tree_dir(folder_type: str, subfolder: str) -> tuple[dict | None, str | None]:
|
||||
"""Return tree { folders: [name], files: [{filename, size, mtime}] } for one level."""
|
||||
if folder_type not in MEDIA_TYPES:
|
||||
return None, "Invalid type"
|
||||
base = _get_media_root(folder_type)
|
||||
@@ -268,7 +349,6 @@ def _tree_dir(folder_type: str, subfolder: str) -> tuple[dict | None, str | None
|
||||
|
||||
|
||||
async def _handle_tree(request: web.Request) -> web.Response:
|
||||
"""GET ?type=output&subfolder= (one level: folders + files)."""
|
||||
folder_type = (request.query.get("type") or "output").strip().lower()
|
||||
subfolder = request.query.get("subfolder", "")
|
||||
data, err = _tree_dir(folder_type, subfolder)
|
||||
@@ -283,7 +363,6 @@ async def _handle_tree(request: web.Request) -> web.Response:
|
||||
|
||||
|
||||
async def _handle_capabilities(request: web.Request) -> web.Response:
|
||||
"""GET /workflowui/media/capabilities — report what this plugin supports."""
|
||||
return web.json_response({
|
||||
"workflowui_plugin": True,
|
||||
"delete": True,
|
||||
@@ -291,29 +370,152 @@ async def _handle_capabilities(request: web.Request) -> web.Response:
|
||||
"tree": True,
|
||||
"view": True,
|
||||
"view_preview": True,
|
||||
"upload": True,
|
||||
})
|
||||
|
||||
|
||||
async def _handle_version_info(request: web.Request) -> web.Response:
|
||||
"""GET /workflowui/version_info — installed custom node modules and versions for run metadata."""
|
||||
from .version_info import get_version_info_payload
|
||||
return web.json_response(get_version_info_payload())
|
||||
|
||||
|
||||
async def _handle_workflows_list(request: web.Request) -> web.Response:
|
||||
from .workflow_export import list_workflows
|
||||
try:
|
||||
items = list_workflows()
|
||||
return web.json_response(items)
|
||||
except Exception as e:
|
||||
logger.warning("WorkflowUIPlugin: workflows list failed: %s", e)
|
||||
return web.json_response([])
|
||||
|
||||
|
||||
async def _handle_workflow_by_id(request: web.Request) -> web.Response:
|
||||
from .workflow_export import load_workflow
|
||||
workflow_id = request.match_info.get("id", "").strip()
|
||||
if not workflow_id:
|
||||
return web.json_response({"error": "Missing workflow id"}, status=400)
|
||||
try:
|
||||
name, graph = load_workflow(workflow_id)
|
||||
return web.json_response({"name": name, "graph": graph})
|
||||
except FileNotFoundError as e:
|
||||
return web.json_response({"error": str(e)}, status=404)
|
||||
except ValueError as e:
|
||||
return web.json_response({"error": str(e)}, status=400)
|
||||
except Exception as e:
|
||||
logger.exception("WorkflowUIPlugin: load workflow %s failed", workflow_id)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
WORKFLOWCONVERTER_MAX_CONTENT_LENGTH = 1 * 1024 * 1024
|
||||
|
||||
|
||||
def _strip_null_bytes(obj):
|
||||
if isinstance(obj, dict):
|
||||
out = {}
|
||||
for k, v in obj.items():
|
||||
key = k
|
||||
if isinstance(k, str) and ("\x00" in k or "\u0000" in k):
|
||||
key = k.replace("\x00", "").replace("\u0000", "")
|
||||
out[key] = _strip_null_bytes(v)
|
||||
return out
|
||||
if isinstance(obj, list):
|
||||
return [_strip_null_bytes(v) for v in obj]
|
||||
if isinstance(obj, str):
|
||||
if "\x00" in obj or "\u0000" in obj:
|
||||
return obj.replace("\x00", "").replace("\u0000", "")
|
||||
return obj
|
||||
return obj
|
||||
|
||||
|
||||
async def _handle_workflowconverter_convert_post(request: web.Request) -> web.Response:
|
||||
logger.info("WorkflowUIPlugin: workflowconverter/convert POST received")
|
||||
if request.content_length is not None and request.content_length > WORKFLOWCONVERTER_MAX_CONTENT_LENGTH:
|
||||
return web.json_response({
|
||||
"success": False,
|
||||
"error": f"Request too large. Maximum size is {WORKFLOWCONVERTER_MAX_CONTENT_LENGTH // (1024 * 1024)} MB",
|
||||
}, status=413)
|
||||
try:
|
||||
raw = await request.read()
|
||||
raw = raw.replace(b"\x00", b"")
|
||||
body = json.loads(raw.decode("utf-8", errors="replace"))
|
||||
except Exception as e:
|
||||
logger.debug("WorkflowUIPlugin: workflowconverter/convert invalid JSON: %s", e)
|
||||
return web.json_response({"success": False, "error": f"Invalid JSON: {e!s}"}, status=400)
|
||||
if not isinstance(body, dict):
|
||||
return web.json_response({"success": False, "error": "Body must be a JSON object"}, status=400)
|
||||
body = _strip_null_bytes(body)
|
||||
try:
|
||||
WorkflowConverter = _load_workflow_converter()
|
||||
if WorkflowConverter.is_api_format(body):
|
||||
return web.json_response(body)
|
||||
if "nodes" not in body or "links" not in body:
|
||||
return web.json_response({
|
||||
"success": False,
|
||||
"error": "Invalid workflow format - missing nodes or links",
|
||||
}, status=400)
|
||||
if not isinstance(body.get("nodes"), list):
|
||||
return web.json_response({
|
||||
"success": False,
|
||||
"error": "Invalid workflow format - 'nodes' must be a list",
|
||||
}, status=400)
|
||||
if not isinstance(body.get("links"), list):
|
||||
return web.json_response({
|
||||
"success": False,
|
||||
"error": "Invalid workflow format - 'links' must be a list",
|
||||
}, status=400)
|
||||
api_prompt = WorkflowConverter.convert_to_api(body)
|
||||
num_nodes = len(body.get("nodes", []))
|
||||
num_links = len(body.get("links", []))
|
||||
num_converted = len(api_prompt)
|
||||
logger.info(
|
||||
"WorkflowUIPlugin: workflowconverter converted %s nodes, %s links -> %s API nodes",
|
||||
num_nodes, num_links, num_converted,
|
||||
)
|
||||
return web.json_response(api_prompt)
|
||||
except Exception as e:
|
||||
import traceback
|
||||
tb = traceback.format_exc()
|
||||
logger.error("WorkflowUIPlugin: workflowconverter/convert failed: %s", e)
|
||||
logger.error("Traceback: %s", tb)
|
||||
err_msg = str(e).strip() or "Internal server error during conversion"
|
||||
if len(err_msg) > 500:
|
||||
err_msg = err_msg[:497] + "..."
|
||||
return web.json_response({
|
||||
"success": False,
|
||||
"error": err_msg,
|
||||
"traceback": tb,
|
||||
}, status=500)
|
||||
|
||||
|
||||
async def _handle_workflowconverter_convert_get(request: web.Request) -> web.Response:
|
||||
return web.json_response({
|
||||
"name": "WorkflowUI Workflow Converter",
|
||||
"version": "2.1.0",
|
||||
"description": "Converts non-API workflow format to API format for execution (subgraphs, bypassed nodes, GetNode/SetNode). Part of WorkflowUIPlugin.",
|
||||
"usage": "POST a workflow JSON to this endpoint to convert it to API format",
|
||||
"endpoint": "/workflowui/workflowconverter/convert",
|
||||
"source": "Based on Seth A. Robinson's comfyui-workflow-to-api-converter-endpoint",
|
||||
"repository": "https://github.com/SethRobinson/comfyui-workflow-to-api-converter-endpoint",
|
||||
})
|
||||
|
||||
|
||||
def register_routes():
|
||||
"""Register routes with ComfyUI PromptServer. Call from __init__.py."""
|
||||
try:
|
||||
from server import PromptServer
|
||||
routes = PromptServer.instance.routes
|
||||
except Exception as e:
|
||||
logger.warning("WorkflowUIPlugin: could not get PromptServer routes: %s", e)
|
||||
return
|
||||
# WorkflowUI backend calls POST /delete with JSON { filename, subfolder, type }
|
||||
routes.post("/delete")(_handle_delete)
|
||||
routes.post("/workflowui/media/delete")(_handle_delete)
|
||||
routes.post("/workflowui/media/upload")(_handle_upload)
|
||||
routes.get("/workflowui/media/view")(_handle_view)
|
||||
routes.get("/workflowui/media/list")(_handle_list)
|
||||
routes.get("/workflowui/media/tree")(_handle_tree)
|
||||
routes.get("/workflowui/media/capabilities")(_handle_capabilities)
|
||||
routes.get("/workflowui/version_info")(_handle_version_info)
|
||||
logger.info("WorkflowUIPlugin: registered media routes (delete, view, list, tree, capabilities, version_info)")
|
||||
routes.get("/workflowui/workflows")(_handle_workflows_list)
|
||||
routes.get("/workflowui/workflows/{id}")(_handle_workflow_by_id)
|
||||
routes.post("/workflowui/workflowconverter/convert")(_handle_workflowconverter_convert_post)
|
||||
routes.get("/workflowui/workflowconverter/convert")(_handle_workflowconverter_convert_get)
|
||||
logger.info("WorkflowUIPlugin: registered media + workflow export + workflowconverter routes")
|
||||
|
||||
+1
-23
@@ -4,7 +4,7 @@ import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
__version__ = "1.0.6"
|
||||
__version__ = "1.0.10"
|
||||
|
||||
_PLUGIN_DIR = Path(__file__).resolve().parent
|
||||
_CUSTOM_NODES_DIR = _PLUGIN_DIR.parent
|
||||
@@ -14,7 +14,6 @@ _PYPROJECT_VERSION_RE = re.compile(r'^\s*version\s*=\s*["\']([^"\']+)["\']', re.
|
||||
|
||||
|
||||
def _version_from_init_py(init_path: Path) -> str | None:
|
||||
"""Try to read __version__ from a package __init__.py without importing."""
|
||||
if not init_path.is_file():
|
||||
return None
|
||||
try:
|
||||
@@ -26,7 +25,6 @@ def _version_from_init_py(init_path: Path) -> str | None:
|
||||
|
||||
|
||||
def _version_from_metadata(package_name: str) -> str | None:
|
||||
"""Try to get version from importlib.metadata (pip-installed packages)."""
|
||||
try:
|
||||
from importlib.metadata import version
|
||||
for name in (package_name, package_name.replace("_", "-"), package_name.replace("-", "_")):
|
||||
@@ -40,7 +38,6 @@ def _version_from_metadata(package_name: str) -> str | None:
|
||||
|
||||
|
||||
def _version_from_pyproject_toml(package_dir: Path) -> str | None:
|
||||
"""Try to read version from pyproject.toml ([project] or [tool.poetry] version)."""
|
||||
path = package_dir / "pyproject.toml"
|
||||
if not path.is_file():
|
||||
return None
|
||||
@@ -54,7 +51,6 @@ def _version_from_pyproject_toml(package_dir: Path) -> str | None:
|
||||
|
||||
|
||||
def _version_from_package_json(package_dir: Path) -> str | None:
|
||||
"""Try to read version from package.json."""
|
||||
path = package_dir / "package.json"
|
||||
if not path.is_file():
|
||||
return None
|
||||
@@ -68,7 +64,6 @@ def _version_from_package_json(package_dir: Path) -> str | None:
|
||||
|
||||
|
||||
def _version_from_version_file(package_dir: Path) -> str | None:
|
||||
"""Try __version__ in version.py (or similar) in the package dir."""
|
||||
for name in ("version.py", "version.json"):
|
||||
path = package_dir / name
|
||||
if not path.is_file():
|
||||
@@ -89,20 +84,10 @@ def _version_from_version_file(package_dir: Path) -> str | None:
|
||||
|
||||
|
||||
def _is_git_repo(package_dir: Path) -> bool:
|
||||
"""True if package_dir is the root of a git repo (has .git)."""
|
||||
return (package_dir / ".git").exists()
|
||||
|
||||
|
||||
def get_installed_modules() -> list[dict]:
|
||||
"""
|
||||
List installed custom node modules (directories under custom_nodes with __init__.py)
|
||||
and their version if discoverable.
|
||||
|
||||
Returns a list of {"name": str, "version": str | null} sorted by name.
|
||||
Version is resolved from: __init__.py, importlib.metadata, pyproject.toml,
|
||||
package.json, version.py; if still unknown and the dir is a git repo, "nightly".
|
||||
Does not import custom node code.
|
||||
"""
|
||||
result: list[dict] = []
|
||||
if not _CUSTOM_NODES_DIR.is_dir():
|
||||
return result
|
||||
@@ -129,13 +114,6 @@ def get_installed_modules() -> list[dict]:
|
||||
|
||||
|
||||
def get_version_info_payload() -> dict:
|
||||
"""
|
||||
Build the JSON payload for GET /workflowui/version_info.
|
||||
|
||||
Includes:
|
||||
- workflowui_plugin_version: this plugin's version
|
||||
- installed_custom_nodes: list of { name, version } for each custom node package
|
||||
"""
|
||||
return {
|
||||
"workflowui_plugin_version": __version__,
|
||||
"installed_custom_nodes": get_installed_modules(),
|
||||
|
||||
@@ -0,0 +1,800 @@
|
||||
import json
|
||||
import logging
|
||||
from typing import Dict, Any, List, Tuple, Optional, Union
|
||||
logger = logging.getLogger(__name__)
|
||||
try:
|
||||
import nodes
|
||||
except ImportError as e:
|
||||
raise ImportError('Cannot import ComfyUI nodes module. This converter must be run within the ComfyUI environment. Make sure ComfyUI is properly initialized before using the converter.') from e
|
||||
_node_info_cache = {}
|
||||
|
||||
def _strip_nulls(s: str) -> str:
|
||||
if not isinstance(s, str):
|
||||
return s
|
||||
return s.replace('\x00', '').replace('\x00', '')
|
||||
|
||||
def get_node_info_for_type(node_type: str) -> Dict[str, Any]:
|
||||
global _node_info_cache
|
||||
node_type = _strip_nulls(node_type)
|
||||
if node_type not in _node_info_cache:
|
||||
if node_type in nodes.NODE_CLASS_MAPPINGS:
|
||||
try:
|
||||
obj_class = nodes.NODE_CLASS_MAPPINGS[node_type]
|
||||
info = {}
|
||||
info['input'] = obj_class.INPUT_TYPES()
|
||||
info['input_order'] = {key: list(value.keys()) for key, value in obj_class.INPUT_TYPES().items()}
|
||||
_node_info_cache[node_type] = info
|
||||
except ValueError as e:
|
||||
if 'null bytes' in str(e).lower():
|
||||
logger.error("WorkflowConverter: node type '%s' triggered 'source code string cannot contain null bytes'. Treating as unknown (using fallback input order). Check this custom node's source or ComfyUI.", node_type, exc_info=True)
|
||||
_node_info_cache[node_type] = None
|
||||
return None
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.debug('Could not get node info for %s: %s', node_type, e)
|
||||
_node_info_cache[node_type] = None
|
||||
else:
|
||||
_node_info_cache[node_type] = None
|
||||
return _node_info_cache.get(node_type)
|
||||
|
||||
class WorkflowConverter:
|
||||
|
||||
@staticmethod
|
||||
def is_subgraph_uuid(node_type: str) -> bool:
|
||||
if not node_type or not isinstance(node_type, str):
|
||||
return False
|
||||
if len(node_type) == 36 and node_type.count('-') == 4:
|
||||
parts = node_type.split('-')
|
||||
if len(parts) == 5 and all((len(p) in [8, 4, 4, 4, 12] for i, p in enumerate(parts) if i == 0 or i == 4 or len(p) == 4)):
|
||||
return True
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def expand_subgraph(subgraph_node_id: int, subgraph_def: Dict[str, Any], workflow_links: List) -> Tuple[List[Dict], List]:
|
||||
expanded_nodes = []
|
||||
expanded_links = []
|
||||
internal_nodes = subgraph_def.get('nodes', [])
|
||||
internal_links = subgraph_def.get('links', [])
|
||||
max_link_id = 0
|
||||
for link in workflow_links:
|
||||
if isinstance(link, (list, tuple)) and len(link) > 0:
|
||||
link_id = link[0]
|
||||
if isinstance(link_id, int) and link_id > max_link_id:
|
||||
max_link_id = link_id
|
||||
link_id_remap = {}
|
||||
next_link_id = max_link_id + 1
|
||||
for link in internal_links:
|
||||
if isinstance(link, dict):
|
||||
old_id = link.get('id')
|
||||
if old_id is not None:
|
||||
link_id_remap[old_id] = next_link_id
|
||||
next_link_id += 1
|
||||
internal_link_map = {}
|
||||
for link in internal_links:
|
||||
if isinstance(link, dict):
|
||||
link_id = link.get('id')
|
||||
internal_link_map[link_id] = link
|
||||
subgraph_inputs = subgraph_def.get('inputs', [])
|
||||
subgraph_outputs = subgraph_def.get('outputs', [])
|
||||
input_slot_to_internal = {}
|
||||
internal_to_output_slot = {}
|
||||
for idx, input_def in enumerate(subgraph_inputs):
|
||||
input_link_ids = input_def.get('linkIds', [])
|
||||
targets = []
|
||||
for link_id in input_link_ids:
|
||||
if link_id in internal_link_map:
|
||||
link = internal_link_map[link_id]
|
||||
target_id = link.get('target_id')
|
||||
target_slot = link.get('target_slot')
|
||||
targets.append((target_id, target_slot))
|
||||
if targets:
|
||||
input_slot_to_internal[idx] = targets
|
||||
for idx, output_def in enumerate(subgraph_outputs):
|
||||
output_link_ids = output_def.get('linkIds', [])
|
||||
for link_id in output_link_ids:
|
||||
if link_id in internal_link_map:
|
||||
link = internal_link_map[link_id]
|
||||
origin_id = link.get('origin_id')
|
||||
origin_slot = link.get('origin_slot')
|
||||
internal_to_output_slot[origin_id, origin_slot] = idx
|
||||
for node in internal_nodes:
|
||||
internal_id = node.get('id')
|
||||
expanded_node = node.copy()
|
||||
expanded_node['id'] = f'{subgraph_node_id}:{internal_id}'
|
||||
if 'inputs' in expanded_node:
|
||||
updated_inputs = []
|
||||
for input_info in expanded_node['inputs']:
|
||||
input_link = input_info.get('link')
|
||||
if input_link in internal_link_map:
|
||||
link_data = internal_link_map[input_link]
|
||||
if link_data.get('origin_id') == -10:
|
||||
input_copy = input_info.copy()
|
||||
input_copy['link'] = None
|
||||
updated_inputs.append(input_copy)
|
||||
else:
|
||||
input_copy = input_info.copy()
|
||||
if input_link in link_id_remap:
|
||||
input_copy['link'] = link_id_remap[input_link]
|
||||
updated_inputs.append(input_copy)
|
||||
else:
|
||||
updated_inputs.append(input_info)
|
||||
expanded_node['inputs'] = updated_inputs
|
||||
expanded_nodes.append(expanded_node)
|
||||
for link in internal_links:
|
||||
if isinstance(link, dict):
|
||||
origin_id = link.get('origin_id')
|
||||
target_id = link.get('target_id')
|
||||
if origin_id in [-10, -20] or target_id in [-10, -20]:
|
||||
continue
|
||||
old_link_id = link.get('id')
|
||||
new_link_id = link_id_remap.get(old_link_id, old_link_id)
|
||||
expanded_link = [new_link_id, f'{subgraph_node_id}:{origin_id}', link.get('origin_slot'), f'{subgraph_node_id}:{target_id}', link.get('target_slot'), link.get('type')]
|
||||
expanded_links.append(expanded_link)
|
||||
return (expanded_nodes, expanded_links, input_slot_to_internal, internal_to_output_slot)
|
||||
|
||||
@staticmethod
|
||||
def is_api_format(workflow: Dict[str, Any]) -> bool:
|
||||
if 'nodes' in workflow and 'links' in workflow:
|
||||
return False
|
||||
for key, value in workflow.items():
|
||||
if key in ['prompt', 'extra_data', 'client_id']:
|
||||
continue
|
||||
if isinstance(value, dict) and 'class_type' in value:
|
||||
return True
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _strip_null_bytes(obj: Any) -> Any:
|
||||
if isinstance(obj, dict):
|
||||
out = {}
|
||||
for k, v in obj.items():
|
||||
key = k.replace('\x00', '').replace('\x00', '') if isinstance(k, str) else k
|
||||
out[key] = WorkflowConverter._strip_null_bytes(v)
|
||||
return out
|
||||
if isinstance(obj, list):
|
||||
return [WorkflowConverter._strip_null_bytes(v) for v in obj]
|
||||
if isinstance(obj, str):
|
||||
return obj.replace('\x00', '').replace('\x00', '')
|
||||
return obj
|
||||
|
||||
@staticmethod
|
||||
def convert_to_api(workflow: Dict[str, Any]) -> Dict[str, Any]:
|
||||
workflow = WorkflowConverter._strip_null_bytes(workflow)
|
||||
if WorkflowConverter.is_api_format(workflow):
|
||||
return workflow
|
||||
workflow_nodes = workflow.get('nodes', [])
|
||||
links = workflow.get('links', [])
|
||||
node_types_in_workflow = sorted(set((n.get('type') or n.get('class_type') for n in workflow_nodes if isinstance(n, dict) and (n.get('type') or n.get('class_type')))))
|
||||
logger.info('WorkflowConverter: starting conversion, %s nodes, %s links. Node types: %s', len(workflow_nodes), len(links), node_types_in_workflow[:40])
|
||||
subgraph_defs = {}
|
||||
definitions = workflow.get('definitions', {})
|
||||
for subgraph in definitions.get('subgraphs', []):
|
||||
subgraph_id = subgraph.get('id')
|
||||
if subgraph_id:
|
||||
subgraph_defs[subgraph_id] = subgraph
|
||||
subgraph_input_mappings = {}
|
||||
subgraph_output_mappings = {}
|
||||
subgraph_slot_to_input_idx = {}
|
||||
max_iterations = 10
|
||||
iteration = 0
|
||||
while iteration < max_iterations:
|
||||
iteration += 1
|
||||
expanded_nodes = []
|
||||
found_subgraph = False
|
||||
for node in workflow_nodes:
|
||||
node_type = _strip_nulls(node.get('type') or '')
|
||||
node_id = node.get('id')
|
||||
if WorkflowConverter.is_subgraph_uuid(node_type) and node_type in subgraph_defs:
|
||||
found_subgraph = True
|
||||
logger.debug(f'Expanding subgraph node {node_id} (type: {node_type}) - iteration {iteration}')
|
||||
subgraph_def = subgraph_defs[node_type]
|
||||
sg_nodes, sg_links, input_map, output_map = WorkflowConverter.expand_subgraph(node_id, subgraph_def, links)
|
||||
expanded_nodes.extend(sg_nodes)
|
||||
links.extend(sg_links)
|
||||
subgraph_input_mappings[str(node_id)] = input_map
|
||||
subgraph_output_mappings[str(node_id)] = output_map
|
||||
outer_inputs = node.get('inputs', [])
|
||||
subgraph_inputs = subgraph_def.get('inputs', [])
|
||||
subgraph_input_name_to_idx = {}
|
||||
for idx, sg_input in enumerate(subgraph_inputs):
|
||||
input_name = sg_input.get('name')
|
||||
if input_name:
|
||||
subgraph_input_name_to_idx[input_name] = idx
|
||||
slot_mapping = {}
|
||||
for outer_slot, outer_input in enumerate(outer_inputs):
|
||||
outer_name = outer_input.get('name')
|
||||
if outer_name and outer_name in subgraph_input_name_to_idx:
|
||||
slot_mapping[outer_slot] = subgraph_input_name_to_idx[outer_name]
|
||||
logger.debug(f"Subgraph {node_id}: outer slot {outer_slot} ('{outer_name}') -> subgraph input idx {subgraph_input_name_to_idx[outer_name]}")
|
||||
subgraph_slot_to_input_idx[str(node_id)] = slot_mapping
|
||||
else:
|
||||
expanded_nodes.append(node)
|
||||
workflow_nodes = expanded_nodes
|
||||
if not found_subgraph:
|
||||
logger.debug(f'Subgraph expansion complete after {iteration} iteration(s)')
|
||||
break
|
||||
if iteration >= max_iterations:
|
||||
logger.warning(f'Reached maximum subgraph expansion iterations ({max_iterations}). There may be circular subgraph references.')
|
||||
|
||||
def resolve_subgraph_output(node_id_str, slot, depth=0):
|
||||
if depth > 100:
|
||||
logger.warning(f'Max recursion depth reached resolving subgraph output for node {node_id_str}')
|
||||
return (node_id_str, slot)
|
||||
if node_id_str in subgraph_output_mappings:
|
||||
output_map = subgraph_output_mappings[node_id_str]
|
||||
for (internal_node, internal_slot), out_slot in output_map.items():
|
||||
if out_slot == slot:
|
||||
new_node_id = f'{node_id_str}:{internal_node}'
|
||||
return resolve_subgraph_output(new_node_id, internal_slot, depth + 1)
|
||||
return (node_id_str, slot)
|
||||
|
||||
def resolve_subgraph_input(node_id_str, slot, depth=0):
|
||||
if depth > 100:
|
||||
logger.warning(f'Max recursion depth reached resolving subgraph input for node {node_id_str}')
|
||||
return (node_id_str, slot)
|
||||
if node_id_str in subgraph_input_mappings:
|
||||
input_map = subgraph_input_mappings[node_id_str]
|
||||
subgraph_input_idx = slot
|
||||
if node_id_str in subgraph_slot_to_input_idx:
|
||||
slot_mapping = subgraph_slot_to_input_idx[node_id_str]
|
||||
if slot in slot_mapping:
|
||||
subgraph_input_idx = slot_mapping[slot]
|
||||
if subgraph_input_idx in input_map:
|
||||
targets = input_map[subgraph_input_idx]
|
||||
if targets:
|
||||
internal_node, internal_slot = targets[0]
|
||||
new_node_id = f'{node_id_str}:{internal_node}'
|
||||
return resolve_subgraph_input(new_node_id, internal_slot, depth + 1)
|
||||
return (node_id_str, slot)
|
||||
|
||||
def resolve_subgraph_input_all(node_id_str, slot, depth=0):
|
||||
if depth > 100:
|
||||
logger.warning(f'Max recursion depth reached resolving subgraph input for node {node_id_str}')
|
||||
return [(node_id_str, slot)]
|
||||
if node_id_str in subgraph_input_mappings:
|
||||
input_map = subgraph_input_mappings[node_id_str]
|
||||
subgraph_input_idx = slot
|
||||
if node_id_str in subgraph_slot_to_input_idx:
|
||||
slot_mapping = subgraph_slot_to_input_idx[node_id_str]
|
||||
if slot in slot_mapping:
|
||||
subgraph_input_idx = slot_mapping[slot]
|
||||
if subgraph_input_idx in input_map:
|
||||
targets = input_map[subgraph_input_idx]
|
||||
results = []
|
||||
for internal_node, internal_slot in targets:
|
||||
new_node_id = f'{node_id_str}:{internal_node}'
|
||||
results.extend(resolve_subgraph_input_all(new_node_id, internal_slot, depth + 1))
|
||||
return results if results else [(node_id_str, slot)]
|
||||
return [(node_id_str, slot)]
|
||||
node_input_updates = {}
|
||||
updated_links = []
|
||||
for link in links:
|
||||
if len(link) >= 6:
|
||||
link_id = link[0]
|
||||
source_id = link[1]
|
||||
source_slot = link[2]
|
||||
target_id = link[3]
|
||||
target_slot = link[4]
|
||||
link_type = link[5] if len(link) > 5 else None
|
||||
source_id_str = str(source_id)
|
||||
source_id, source_slot = resolve_subgraph_output(source_id_str, source_slot)
|
||||
target_id_str = str(target_id)
|
||||
all_targets = resolve_subgraph_input_all(target_id_str, target_slot)
|
||||
for resolved_target_id, resolved_target_slot in all_targets:
|
||||
if resolved_target_id != target_id_str:
|
||||
if resolved_target_id not in node_input_updates:
|
||||
node_input_updates[resolved_target_id] = {}
|
||||
node_input_updates[resolved_target_id][resolved_target_slot] = link_id
|
||||
resolved_target_id, resolved_target_slot = all_targets[0]
|
||||
target_id = resolved_target_id
|
||||
target_slot = resolved_target_slot
|
||||
updated_links.append([link_id, source_id, source_slot, target_id, target_slot, link_type])
|
||||
links = updated_links
|
||||
for node in workflow_nodes:
|
||||
node_id_str = str(node.get('id'))
|
||||
if node_id_str in node_input_updates and 'inputs' in node:
|
||||
slot_to_link = node_input_updates[node_id_str]
|
||||
inputs = node.get('inputs', [])
|
||||
for i, input_info in enumerate(inputs):
|
||||
if i in slot_to_link:
|
||||
input_info['link'] = slot_to_link[i]
|
||||
link_map = {}
|
||||
nodes_with_connected_outputs = set()
|
||||
for link in links:
|
||||
if len(link) >= 6:
|
||||
link_id = link[0]
|
||||
source_id = link[1]
|
||||
source_slot = link[2]
|
||||
target_id = link[3]
|
||||
target_slot = link[4]
|
||||
link_type = link[5] if len(link) > 5 else None
|
||||
link_map[link_id] = {'source_id': source_id, 'source_slot': source_slot, 'target_id': target_id, 'target_slot': target_slot, 'type': link_type}
|
||||
nodes_with_connected_outputs.add(source_id)
|
||||
primitive_values = {}
|
||||
nodes_to_exclude = set()
|
||||
bypassed_nodes = set()
|
||||
set_node_sources = {}
|
||||
get_node_vars = {}
|
||||
node_by_id = {}
|
||||
for node in workflow_nodes:
|
||||
node_by_id[str(node.get('id'))] = node
|
||||
for node in workflow_nodes:
|
||||
node_id = node.get('id')
|
||||
node_type = _strip_nulls(node.get('type') or '')
|
||||
node_mode = node.get('mode', 0)
|
||||
if node_mode == 4:
|
||||
bypassed_nodes.add(str(node_id))
|
||||
logger.debug(f'Tracking bypassed node {node_id} ({node_type})')
|
||||
if node_type == 'PrimitiveNode':
|
||||
node_id_str = str(node_id)
|
||||
widget_values = node.get('widgets_values')
|
||||
if widget_values and len(widget_values) > 0:
|
||||
primitive_values[node_id_str] = widget_values[0]
|
||||
elif node_type == 'SetNode':
|
||||
widget_values = node.get('widgets_values')
|
||||
if widget_values and len(widget_values) > 0:
|
||||
var_name = widget_values[0]
|
||||
node_inputs = node.get('inputs', [])
|
||||
if node_inputs:
|
||||
for input_info in node_inputs:
|
||||
input_link = input_info.get('link')
|
||||
if input_link is not None and input_link in link_map:
|
||||
link_data = link_map[input_link]
|
||||
set_node_sources[var_name] = (link_data['source_id'], link_data['source_slot'])
|
||||
logger.debug(f"SetNode '{var_name}' receives from node {link_data['source_id']} slot {link_data['source_slot']}")
|
||||
break
|
||||
elif node_type == 'GetNode':
|
||||
widget_values = node.get('widgets_values')
|
||||
if widget_values and len(widget_values) > 0:
|
||||
var_name = widget_values[0]
|
||||
get_node_vars[str(node_id)] = var_name
|
||||
logger.debug(f"GetNode {node_id} provides variable '{var_name}'")
|
||||
outputs = node.get('outputs', [])
|
||||
has_connected_output = False
|
||||
for output in outputs:
|
||||
output_links = output.get('links', [])
|
||||
if output_links and len(output_links) > 0:
|
||||
has_connected_output = True
|
||||
break
|
||||
if node_type == 'LoadImageOutput':
|
||||
nodes_to_exclude.add(str(node_id))
|
||||
logger.debug(f'Marking node {node_id} ({node_type}) for exclusion - UI-only node type')
|
||||
elif not outputs or not has_connected_output:
|
||||
node_class = nodes.NODE_CLASS_MAPPINGS.get(node_type) if hasattr(nodes, 'NODE_CLASS_MAPPINGS') else None
|
||||
is_output_node = node_class and hasattr(node_class, 'OUTPUT_NODE') and node_class.OUTPUT_NODE
|
||||
has_connected_input = False
|
||||
if not is_output_node and (not node_class):
|
||||
node_inputs = node.get('inputs', [])
|
||||
for input_info in node_inputs:
|
||||
if input_info.get('link') is not None:
|
||||
has_connected_input = True
|
||||
break
|
||||
if not is_output_node and (not has_connected_input):
|
||||
nodes_to_exclude.add(str(node_id))
|
||||
logger.debug(f'Marking node {node_id} ({node_type}) for exclusion - no connected outputs')
|
||||
else:
|
||||
logger.debug(f'Keeping node {node_id} ({node_type}) - OUTPUT_NODE=True or has connected inputs despite no connected outputs')
|
||||
|
||||
def trace_through_get_set_nodes(source_node_id, source_slot, visited=None):
|
||||
if visited is None:
|
||||
visited = set()
|
||||
source_node_id_str = str(source_node_id)
|
||||
if source_node_id_str in visited:
|
||||
return (source_node_id, source_slot)
|
||||
visited.add(source_node_id_str)
|
||||
if source_node_id_str in get_node_vars:
|
||||
var_name = get_node_vars[source_node_id_str]
|
||||
if var_name in set_node_sources:
|
||||
actual_source_id, actual_source_slot = set_node_sources[var_name]
|
||||
logger.debug(f"Tracing GetNode {source_node_id} (var '{var_name}') -> actual source {actual_source_id} slot {actual_source_slot}")
|
||||
return trace_through_get_set_nodes(actual_source_id, actual_source_slot, visited)
|
||||
else:
|
||||
logger.warning(f"GetNode {source_node_id} references variable '{var_name}' but no SetNode found for it")
|
||||
return (source_node_id, source_slot)
|
||||
|
||||
def trace_through_bypassed(source_node_id, source_slot, visited=None):
|
||||
if visited is None:
|
||||
visited = set()
|
||||
if source_node_id in visited:
|
||||
return (source_node_id, source_slot)
|
||||
visited.add(source_node_id)
|
||||
if source_node_id not in bypassed_nodes:
|
||||
return (source_node_id, source_slot)
|
||||
for node in workflow_nodes:
|
||||
if str(node.get('id')) == str(source_node_id):
|
||||
node_type = node.get('type', 'unknown')
|
||||
node_inputs = node.get('inputs', [])
|
||||
node_outputs = node.get('outputs', [])
|
||||
output_type = None
|
||||
if node_outputs and source_slot < len(node_outputs):
|
||||
output_type = node_outputs[source_slot].get('type')
|
||||
if node_inputs:
|
||||
linked_input = None
|
||||
fallback_linked_input = None
|
||||
for idx, input_info in enumerate(node_inputs):
|
||||
input_link = input_info.get('link')
|
||||
input_type = input_info.get('type')
|
||||
if input_link is not None and input_link in link_map:
|
||||
if fallback_linked_input is None:
|
||||
fallback_linked_input = input_link
|
||||
if output_type and input_type == output_type:
|
||||
linked_input = input_link
|
||||
break
|
||||
if linked_input is None:
|
||||
linked_input = fallback_linked_input
|
||||
if linked_input is not None:
|
||||
link_data = link_map[linked_input]
|
||||
upstream_source_id = link_data['source_id']
|
||||
upstream_source_slot = link_data['source_slot']
|
||||
upstream_source_id, upstream_source_slot = trace_through_get_set_nodes(upstream_source_id, upstream_source_slot)
|
||||
return trace_through_bypassed(upstream_source_id, upstream_source_slot, visited)
|
||||
break
|
||||
return (source_node_id, source_slot)
|
||||
api_prompt = {}
|
||||
for node in workflow_nodes:
|
||||
node_id = str(node.get('id'))
|
||||
node_type = _strip_nulls(node.get('type') or '')
|
||||
if not node_type:
|
||||
continue
|
||||
node_mode = node.get('mode', 0)
|
||||
if node_mode == 2:
|
||||
logger.debug(f'Skipping muted node {node_id} ({node_type})')
|
||||
continue
|
||||
elif node_mode == 4:
|
||||
logger.debug(f'Skipping bypassed/disabled node {node_id} ({node_type})')
|
||||
continue
|
||||
if node_type in ['Note', 'PrimitiveNode', 'GetNode', 'SetNode']:
|
||||
logger.debug(f'Skipping {node_type} node {node_id}')
|
||||
continue
|
||||
if node_id in nodes_to_exclude:
|
||||
logger.debug(f'Skipping {node_type} node {node_id} - no connected outputs')
|
||||
continue
|
||||
api_node = {'inputs': {}, 'class_type': node_type}
|
||||
if 'title' in node:
|
||||
api_node['_meta'] = {'title': node['title']}
|
||||
elif hasattr(nodes, 'NODE_DISPLAY_NAME_MAPPINGS') and node_type in nodes.NODE_DISPLAY_NAME_MAPPINGS:
|
||||
api_node['_meta'] = {'title': nodes.NODE_DISPLAY_NAME_MAPPINGS[node_type]}
|
||||
else:
|
||||
api_node['_meta'] = {'title': node_type}
|
||||
link_inputs = {}
|
||||
primitive_inputs = {}
|
||||
node_inputs = node.get('inputs', [])
|
||||
if node_inputs:
|
||||
for input_info in node_inputs:
|
||||
input_name = input_info.get('name')
|
||||
input_link = input_info.get('link')
|
||||
if input_link is not None and input_link in link_map:
|
||||
link_data = link_map[input_link]
|
||||
source_node_id = link_data['source_id']
|
||||
source_slot = link_data['source_slot']
|
||||
actual_source_id, actual_source_slot = trace_through_get_set_nodes(source_node_id, source_slot)
|
||||
if str(actual_source_id) in bypassed_nodes:
|
||||
actual_source_id, actual_source_slot = trace_through_bypassed(actual_source_id, actual_source_slot)
|
||||
if str(actual_source_id) in bypassed_nodes:
|
||||
logger.debug(f'Could not trace through bypassed node {source_node_id} for input {input_name}, falling back to widget value')
|
||||
continue
|
||||
actual_source_id, actual_source_slot = trace_through_get_set_nodes(actual_source_id, actual_source_slot)
|
||||
actual_source_id, actual_source_slot = resolve_subgraph_output(str(actual_source_id), actual_source_slot)
|
||||
source_node_id_str = str(actual_source_id)
|
||||
if source_node_id_str in primitive_values:
|
||||
primitive_inputs[input_name] = primitive_values[source_node_id_str]
|
||||
elif actual_source_id in nodes_to_exclude:
|
||||
logger.debug(f'Skipping input {input_name} from excluded node {source_node_id_str}')
|
||||
elif actual_source_id in bypassed_nodes:
|
||||
logger.warning(f'Could not resolve bypassed node {source_node_id} for input {input_name}, skipping connection')
|
||||
else:
|
||||
if actual_source_id != source_node_id:
|
||||
logger.info(f'Bypassed disabled node {source_node_id}, connecting {input_name} to {actual_source_id} (slot {actual_source_slot}) instead')
|
||||
link_inputs[input_name] = [str(actual_source_id), actual_source_slot]
|
||||
logger.info('WorkflowConverter: processing node id=%s type=%s', node_id, node_type)
|
||||
ordered_inputs = WorkflowConverter._get_ordered_inputs(node_type, node)
|
||||
widget_values = node.get('widgets_values')
|
||||
widget_inputs = {}
|
||||
if widget_values is not None:
|
||||
if isinstance(widget_values, dict):
|
||||
for key, value in widget_values.items():
|
||||
if key in ['videopreview', 'preview']:
|
||||
continue
|
||||
if key not in link_inputs:
|
||||
widget_inputs[key] = value
|
||||
elif isinstance(widget_values, list):
|
||||
has_dict_widgets = any((isinstance(v, dict) for v in widget_values))
|
||||
if has_dict_widgets:
|
||||
WorkflowConverter._process_dict_widget_values(widget_values, widget_inputs, link_inputs)
|
||||
else:
|
||||
widget_mappings = WorkflowConverter._get_widget_mappings(node_type, node)
|
||||
filtered_values = WorkflowConverter._filter_control_values(widget_values)
|
||||
if widget_mappings:
|
||||
for i, value in enumerate(filtered_values):
|
||||
if i < len(widget_mappings):
|
||||
widget_name = widget_mappings[i]
|
||||
if widget_name and widget_name not in link_inputs:
|
||||
widget_inputs[widget_name] = value
|
||||
elif filtered_values:
|
||||
logger.warning(f"Could not map widget values for unknown node type '{node_type}' (node {node_id})")
|
||||
default_inputs = WorkflowConverter._get_default_inputs(node_type, widget_inputs, primitive_inputs, link_inputs)
|
||||
if ordered_inputs:
|
||||
for input_name in ordered_inputs:
|
||||
if input_name in widget_inputs:
|
||||
api_node['inputs'][input_name] = widget_inputs[input_name]
|
||||
elif input_name in primitive_inputs:
|
||||
api_node['inputs'][input_name] = primitive_inputs[input_name]
|
||||
elif input_name in default_inputs:
|
||||
api_node['inputs'][input_name] = default_inputs[input_name]
|
||||
for input_name in ordered_inputs:
|
||||
if input_name in link_inputs and input_name not in api_node['inputs']:
|
||||
api_node['inputs'][input_name] = link_inputs[input_name]
|
||||
for key, value in widget_inputs.items():
|
||||
if key not in api_node['inputs']:
|
||||
api_node['inputs'][key] = value
|
||||
for key, value in primitive_inputs.items():
|
||||
if key not in api_node['inputs']:
|
||||
api_node['inputs'][key] = value
|
||||
for key, value in default_inputs.items():
|
||||
if key not in api_node['inputs']:
|
||||
api_node['inputs'][key] = value
|
||||
for key, value in link_inputs.items():
|
||||
if key not in api_node['inputs']:
|
||||
api_node['inputs'][key] = value
|
||||
else:
|
||||
for key, value in widget_inputs.items():
|
||||
api_node['inputs'][key] = value
|
||||
for key, value in primitive_inputs.items():
|
||||
if key not in api_node['inputs']:
|
||||
api_node['inputs'][key] = value
|
||||
for key, value in default_inputs.items():
|
||||
if key not in api_node['inputs']:
|
||||
api_node['inputs'][key] = value
|
||||
for key, value in link_inputs.items():
|
||||
if key not in api_node['inputs']:
|
||||
api_node['inputs'][key] = value
|
||||
api_prompt[node_id] = api_node
|
||||
return api_prompt
|
||||
|
||||
@staticmethod
|
||||
def _process_dict_widget_values(widget_values: List[Any], widget_inputs: Dict[str, Any], link_inputs: Dict[str, Any]) -> None:
|
||||
lora_counter = 0
|
||||
for value in widget_values:
|
||||
if isinstance(value, dict):
|
||||
if not value:
|
||||
continue
|
||||
elif 'type' in value:
|
||||
widget_name = value.get('type')
|
||||
if widget_name and widget_name not in link_inputs:
|
||||
widget_inputs[widget_name] = value
|
||||
elif 'lora' in value:
|
||||
lora_counter += 1
|
||||
widget_name = f'lora_{lora_counter}'
|
||||
if widget_name not in link_inputs:
|
||||
clean_value = {k: v for k, v in value.items() if k != 'strengthTwo' or v is not None}
|
||||
widget_inputs[widget_name] = clean_value
|
||||
else:
|
||||
logger.debug(f'Unknown dict widget value structure: {value}')
|
||||
elif isinstance(value, str):
|
||||
if value == '':
|
||||
widget_inputs['➕ Add Lora'] = value
|
||||
|
||||
@staticmethod
|
||||
def _filter_control_values(widget_values: List[Any]) -> List[Any]:
|
||||
filtered = []
|
||||
skip_next = False
|
||||
for i, value in enumerate(widget_values):
|
||||
if skip_next:
|
||||
skip_next = False
|
||||
continue
|
||||
if value in ['fixed', 'increment', 'decrement', 'randomize']:
|
||||
continue
|
||||
if i + 1 < len(widget_values):
|
||||
next_val = widget_values[i + 1]
|
||||
if next_val in ['fixed', 'increment', 'decrement', 'randomize']:
|
||||
filtered.append(value)
|
||||
continue
|
||||
filtered.append(value)
|
||||
return filtered
|
||||
|
||||
@staticmethod
|
||||
def _get_ordered_inputs(node_type: str, node: Dict[str, Any]) -> List[str]:
|
||||
node_type = _strip_nulls(node_type)
|
||||
properties = node.get('properties', {})
|
||||
if 'Node name for S&R' in properties:
|
||||
node_type = _strip_nulls(properties['Node name for S&R'])
|
||||
node_info = get_node_info_for_type(node_type)
|
||||
if node_info and 'input_order' in node_info:
|
||||
input_order = node_info['input_order']
|
||||
input_names = []
|
||||
for section in ['required', 'optional']:
|
||||
if section in input_order:
|
||||
input_names.extend(input_order[section])
|
||||
if input_names:
|
||||
return input_names
|
||||
if hasattr(nodes, 'NODE_CLASS_MAPPINGS') and node_type in nodes.NODE_CLASS_MAPPINGS:
|
||||
try:
|
||||
node_class = nodes.NODE_CLASS_MAPPINGS[node_type]
|
||||
input_types = node_class.INPUT_TYPES()
|
||||
input_names = []
|
||||
for section in ['required', 'optional']:
|
||||
if section in input_types:
|
||||
for input_name in input_types[section].keys():
|
||||
input_names.append(input_name)
|
||||
if input_names:
|
||||
return input_names
|
||||
except ValueError as e:
|
||||
if 'null bytes' in str(e).lower():
|
||||
logger.warning("WorkflowConverter: node type '%s' raised null bytes in INPUT_TYPES(); using fallback order", node_type)
|
||||
return []
|
||||
except Exception as e:
|
||||
logger.debug('Could not get input order from node class for %s: %s', node_type, e)
|
||||
return []
|
||||
|
||||
@staticmethod
|
||||
def _get_dynamic_combo_sub_inputs(input_name: str, input_spec: Any, widget_values: List[Any], current_idx: int) -> List[str]:
|
||||
if not isinstance(input_spec, (list, tuple)) or len(input_spec) < 2:
|
||||
return []
|
||||
input_type = input_spec[0]
|
||||
if not isinstance(input_type, str) or not input_type.startswith('COMFY_') or 'COMBO' not in input_type:
|
||||
return []
|
||||
spec_options = input_spec[1] if len(input_spec) > 1 else {}
|
||||
if not isinstance(spec_options, dict):
|
||||
return []
|
||||
options = spec_options.get('options', [])
|
||||
if not options or current_idx >= len(widget_values):
|
||||
return []
|
||||
selected_value = widget_values[current_idx]
|
||||
for option in options:
|
||||
if isinstance(option, dict) and option.get('key') == selected_value:
|
||||
sub_inputs = option.get('inputs', {})
|
||||
sub_input_names = []
|
||||
for section in ['required', 'optional']:
|
||||
if section in sub_inputs:
|
||||
for sub_name in sub_inputs[section].keys():
|
||||
sub_input_names.append(f'{input_name}.{sub_name}')
|
||||
return sub_input_names
|
||||
return []
|
||||
|
||||
@staticmethod
|
||||
def _get_widget_mappings(node_type: str, node: Dict[str, Any]) -> List[Optional[str]]:
|
||||
node_type = _strip_nulls(node_type)
|
||||
properties = node.get('properties', {})
|
||||
if 'Node name for S&R' in properties:
|
||||
node_type = _strip_nulls(properties['Node name for S&R'])
|
||||
widget_values = node.get('widgets_values', [])
|
||||
if isinstance(widget_values, dict):
|
||||
widget_values = []
|
||||
node_info = get_node_info_for_type(node_type)
|
||||
if node_info and 'input' in node_info:
|
||||
try:
|
||||
input_def = node_info['input']
|
||||
widget_names = []
|
||||
widget_idx = 0
|
||||
for section in ['required', 'optional']:
|
||||
if section in input_def:
|
||||
for input_name, input_spec in input_def[section].items():
|
||||
if isinstance(input_spec, (list, tuple)) and len(input_spec) >= 1:
|
||||
input_type = input_spec[0]
|
||||
is_widget = False
|
||||
is_dynamic_combo = False
|
||||
if isinstance(input_type, list):
|
||||
is_widget = True
|
||||
elif input_type in ['INT', 'FLOAT', 'STRING', 'BOOLEAN', 'COMBO']:
|
||||
is_widget = True
|
||||
elif isinstance(input_type, str) and input_type.startswith('COMFY_') and ('COMBO' in input_type):
|
||||
is_widget = True
|
||||
is_dynamic_combo = True
|
||||
elif isinstance(input_type, str) and (not input_type.isupper()):
|
||||
is_widget = True
|
||||
if is_widget:
|
||||
widget_names.append(input_name)
|
||||
if is_dynamic_combo and widget_values:
|
||||
sub_inputs = WorkflowConverter._get_dynamic_combo_sub_inputs(input_name, input_spec, widget_values, widget_idx)
|
||||
widget_names.extend(sub_inputs)
|
||||
widget_idx += 1 + len(sub_inputs)
|
||||
else:
|
||||
widget_idx += 1
|
||||
if widget_names:
|
||||
return widget_names
|
||||
except Exception as e:
|
||||
logger.debug(f'Could not get widget mappings from node info for {node_type}: {e}')
|
||||
if hasattr(nodes, 'NODE_CLASS_MAPPINGS') and node_type in nodes.NODE_CLASS_MAPPINGS:
|
||||
try:
|
||||
node_class = nodes.NODE_CLASS_MAPPINGS[node_type]
|
||||
input_types = node_class.INPUT_TYPES()
|
||||
widget_names = []
|
||||
widget_idx = 0
|
||||
for section in ['required', 'optional']:
|
||||
if section in input_types:
|
||||
for input_name, input_spec in input_types[section].items():
|
||||
if isinstance(input_spec, tuple) and len(input_spec) >= 1:
|
||||
input_type = input_spec[0]
|
||||
is_widget = False
|
||||
is_dynamic_combo = False
|
||||
if isinstance(input_type, list):
|
||||
is_widget = True
|
||||
elif input_type in ['INT', 'FLOAT', 'STRING', 'BOOLEAN', 'COMBO']:
|
||||
is_widget = True
|
||||
elif isinstance(input_type, str) and input_type.startswith('COMFY_') and ('COMBO' in input_type):
|
||||
is_widget = True
|
||||
is_dynamic_combo = True
|
||||
elif isinstance(input_type, str) and (not input_type.isupper()):
|
||||
is_widget = True
|
||||
if is_widget:
|
||||
widget_names.append(input_name)
|
||||
if is_dynamic_combo and widget_values:
|
||||
sub_inputs = WorkflowConverter._get_dynamic_combo_sub_inputs(input_name, input_spec, widget_values, widget_idx)
|
||||
widget_names.extend(sub_inputs)
|
||||
widget_idx += 1 + len(sub_inputs)
|
||||
else:
|
||||
widget_idx += 1
|
||||
if widget_names:
|
||||
return widget_names
|
||||
except ValueError as e:
|
||||
if 'null bytes' in str(e).lower():
|
||||
logger.warning("WorkflowConverter: node type '%s' raised null bytes in INPUT_TYPES(); using fallback widget order", node_type)
|
||||
return []
|
||||
except Exception as e:
|
||||
logger.debug('Could not get widget mappings from node class for %s: %s', node_type, e)
|
||||
widget_values = node.get('widgets_values', [])
|
||||
if not isinstance(widget_values, list) or len(widget_values) == 0:
|
||||
return []
|
||||
properties = node.get('properties', {})
|
||||
ue_properties = properties.get('ue_properties', {})
|
||||
widget_ue_connectable = ue_properties.get('widget_ue_connectable', {})
|
||||
if widget_ue_connectable and isinstance(widget_ue_connectable, dict):
|
||||
widget_names = list(widget_ue_connectable.keys())
|
||||
if widget_names and len(widget_names) >= len(widget_values):
|
||||
return widget_names[:len(widget_values)]
|
||||
all_inputs = []
|
||||
connected_inputs = set()
|
||||
widget_flagged_inputs = []
|
||||
for input_info in node.get('inputs', []):
|
||||
input_name = input_info.get('name')
|
||||
if input_name:
|
||||
all_inputs.append(input_name)
|
||||
if input_info.get('link') is not None:
|
||||
connected_inputs.add(input_name)
|
||||
if input_info.get('widget'):
|
||||
widget_flagged_inputs.append(input_name)
|
||||
if widget_flagged_inputs:
|
||||
if len(widget_values) > len(widget_flagged_inputs):
|
||||
potential_widgets = [inp for inp in all_inputs if inp not in connected_inputs and inp not in widget_flagged_inputs]
|
||||
return widget_flagged_inputs + potential_widgets[:len(widget_values) - len(widget_flagged_inputs)]
|
||||
return widget_flagged_inputs
|
||||
unconnected = [inp for inp in all_inputs if inp not in connected_inputs]
|
||||
if unconnected and len(unconnected) >= len(widget_values):
|
||||
return unconnected[:len(widget_values)]
|
||||
return []
|
||||
|
||||
@staticmethod
|
||||
def _get_default_inputs(node_type: str, widget_inputs: Dict[str, Any], primitive_inputs: Dict[str, Any], link_inputs: Dict[str, Any]) -> Dict[str, Any]:
|
||||
default_inputs = {}
|
||||
node_type = _strip_nulls(node_type)
|
||||
if not hasattr(nodes, 'NODE_CLASS_MAPPINGS') or node_type not in nodes.NODE_CLASS_MAPPINGS:
|
||||
return default_inputs
|
||||
try:
|
||||
node_class = nodes.NODE_CLASS_MAPPINGS[node_type]
|
||||
input_types = node_class.INPUT_TYPES()
|
||||
for section in ['required', 'optional']:
|
||||
if section not in input_types:
|
||||
continue
|
||||
for input_name, input_spec in input_types[section].items():
|
||||
if input_name in widget_inputs or input_name in primitive_inputs or input_name in link_inputs:
|
||||
continue
|
||||
if isinstance(input_spec, (list, tuple)) and len(input_spec) >= 1:
|
||||
input_type = input_spec[0]
|
||||
spec_options = input_spec[1] if len(input_spec) >= 2 else {}
|
||||
if isinstance(spec_options, dict) and 'default' in spec_options:
|
||||
default_inputs[input_name] = spec_options['default']
|
||||
logger.debug(f"Adding default value for {node_type}.{input_name}: {spec_options['default']}")
|
||||
elif isinstance(input_type, list) and len(input_type) > 0:
|
||||
default_inputs[input_name] = input_type[0]
|
||||
logger.debug(f'Adding implicit combo default for {node_type}.{input_name}: {input_type[0]}')
|
||||
elif input_type == 'COMBO' and isinstance(spec_options, dict) and ('options' in spec_options):
|
||||
options = spec_options['options']
|
||||
if isinstance(options, list) and len(options) > 0:
|
||||
default_inputs[input_name] = options[0]
|
||||
logger.debug(f'Adding implicit COMBO default for {node_type}.{input_name}: {options[0]}')
|
||||
except ValueError as e:
|
||||
if 'null bytes' in str(e).lower():
|
||||
logger.warning("WorkflowConverter: node type '%s' raised null bytes in _get_default_inputs; skipping defaults", node_type)
|
||||
except Exception as e:
|
||||
logger.debug('Could not get default inputs for %s: %s', node_type, e)
|
||||
return default_inputs
|
||||
@@ -0,0 +1,248 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_PLUGIN_DIR = Path(__file__).resolve().parent
|
||||
_COMFYUI_ROOT = _PLUGIN_DIR.parent.parent
|
||||
|
||||
def _workflow_ui_link_widget_order() -> list[str]:
|
||||
order = ["form_label"]
|
||||
for i in range(8):
|
||||
order.append(f"type_{i}")
|
||||
order.append(f"name_{i}")
|
||||
for i in range(8):
|
||||
order.append(f"input_text_{i}")
|
||||
for i in range(8):
|
||||
order.append(f"input_number_{i}")
|
||||
for i in range(8):
|
||||
order.append(f"input_image_{i}")
|
||||
for i in range(8):
|
||||
order.append(f"input_video_{i}")
|
||||
for i in range(8):
|
||||
order.append(f"input_audio_{i}")
|
||||
for i in range(8):
|
||||
order.append(f"input_boolean_{i}")
|
||||
return order
|
||||
|
||||
_WIDGET_ORDER: dict[str, list[str]] = {
|
||||
"WorkflowUILink": _workflow_ui_link_widget_order(),
|
||||
"WorkflowUI Link": _workflow_ui_link_widget_order(),
|
||||
"CLIPTextEncode": ["text"],
|
||||
"Text Multiline": ["text"],
|
||||
"PrimitiveStringMultiline": ["value"],
|
||||
"EmptyLatentImage": ["width", "height", "batch_size"],
|
||||
"EmptySD3LatentImage": ["width", "height", "batch_size"],
|
||||
"SDXLEmptyLatentSizePicker+": ["resolution", "batch_size", "width_override", "height_override"],
|
||||
"KSampler": ["seed", "steps", "cfg", "sampler_name", "scheduler", "denoise"],
|
||||
"BasicScheduler": ["scheduler", "steps", "denoise"],
|
||||
"UNETLoader": ["unet_name", "weight_dtype"],
|
||||
"DiffusionModelLoader": ["unet_name", "weight_dtype"],
|
||||
"LoadDiffusionModel": ["unet_name", "weight_dtype"],
|
||||
"StableCascadeCheckpointLoader": ["key_opt_b", "key_opt_c", "cache_mode"],
|
||||
"StableCascade_CheckpointLoader": ["key_opt_b", "key_opt_c", "cache_mode"],
|
||||
"SD3CheckpointLoader": ["ckpt_name", "shift"],
|
||||
"SD3LoadCheckpoint": ["ckpt_name", "shift"],
|
||||
"SaveImage": ["filename_prefix"],
|
||||
"Save Image": ["filename_prefix"],
|
||||
"Save Image (api)": ["filename_prefix"],
|
||||
"SaveImageNode": ["filename_prefix"],
|
||||
}
|
||||
|
||||
|
||||
def get_workflows_directory() -> Path | None:
|
||||
env_path = os.environ.get("WORKFLOWUI_WORKFLOWS_DIR", "").strip()
|
||||
if env_path:
|
||||
p = Path(env_path).resolve()
|
||||
if p.is_dir():
|
||||
return p
|
||||
return None
|
||||
default = _COMFYUI_ROOT / "user" / "default" / "workflows"
|
||||
if default.is_dir():
|
||||
return default
|
||||
return None
|
||||
|
||||
|
||||
def list_workflows() -> list[dict[str, str]]:
|
||||
wf_dir = get_workflows_directory()
|
||||
if not wf_dir:
|
||||
return []
|
||||
result: list[dict[str, str]] = []
|
||||
try:
|
||||
for path in sorted(wf_dir.glob("*.json")):
|
||||
if path.is_file():
|
||||
stem = path.stem
|
||||
label = stem.replace("_", " ").strip() or stem
|
||||
result.append({"id": stem, "label": label})
|
||||
except OSError as e:
|
||||
logger.warning("WorkflowUIPlugin: list_workflows failed: %s", e)
|
||||
return result
|
||||
|
||||
|
||||
def _is_api_format(data: dict[str, Any]) -> bool:
|
||||
if not data or not isinstance(data, dict):
|
||||
return False
|
||||
for v in data.values():
|
||||
if isinstance(v, dict) and ("class_type" in v or "inputs" in v):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _extract_graph(workflow: dict[str, Any]) -> dict[str, Any]:
|
||||
if not isinstance(workflow, dict):
|
||||
return workflow
|
||||
if workflow.get("nodes") is not None:
|
||||
return workflow
|
||||
if _is_api_format(workflow):
|
||||
return workflow
|
||||
for key in ("graph", "workflow", "prompt"):
|
||||
inner = workflow.get(key)
|
||||
if isinstance(inner, dict):
|
||||
if inner.get("nodes") is not None or _is_api_format(inner):
|
||||
return inner
|
||||
deeper = inner.get("graph") or inner.get("workflow")
|
||||
if isinstance(deeper, dict):
|
||||
return deeper
|
||||
return workflow
|
||||
|
||||
|
||||
def _build_link_map(links: list[Any]) -> dict[int | str, tuple[str, int]]:
|
||||
result: dict[int | str, tuple[str, int]] = {}
|
||||
if not isinstance(links, list):
|
||||
return result
|
||||
for link in links:
|
||||
if not isinstance(link, (list, tuple)) or len(link) < 6:
|
||||
continue
|
||||
link_id, origin_id, origin_slot = link[0], link[1], link[2]
|
||||
try:
|
||||
origin_slot_int = int(origin_slot)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
result[link_id] = (str(origin_id), origin_slot_int)
|
||||
return result
|
||||
|
||||
|
||||
def _inputs_from_ui_node(
|
||||
node: dict[str, Any],
|
||||
class_type: str,
|
||||
link_map: dict[int | str, tuple[str, int]],
|
||||
) -> dict[str, Any]:
|
||||
inputs: dict[str, Any] = {}
|
||||
node_inputs = node.get("inputs")
|
||||
widgets_values = (
|
||||
node.get("widgets_values")
|
||||
or node.get("widgetsValues")
|
||||
or node.get("widget_values")
|
||||
)
|
||||
widget_order = _WIDGET_ORDER.get(class_type)
|
||||
|
||||
if isinstance(widgets_values, dict):
|
||||
inputs = dict(widgets_values)
|
||||
elif isinstance(widgets_values, list) and widget_order:
|
||||
for idx, field in enumerate(widget_order):
|
||||
if idx < len(widgets_values):
|
||||
inputs[field] = widgets_values[idx]
|
||||
|
||||
if isinstance(node_inputs, list):
|
||||
widget_only_names: list[str] = []
|
||||
for inp in node_inputs:
|
||||
if not isinstance(inp, dict):
|
||||
continue
|
||||
name = inp.get("name")
|
||||
if not name or not isinstance(name, str):
|
||||
continue
|
||||
link = inp.get("link")
|
||||
if link is not None:
|
||||
conn = link_map.get(link)
|
||||
if conn is not None:
|
||||
inputs[name] = [conn[0], conn[1]]
|
||||
else:
|
||||
widget_only_names.append(name)
|
||||
for idx, name in enumerate(widget_only_names):
|
||||
if name not in inputs and isinstance(widgets_values, list) and idx < len(widgets_values):
|
||||
inputs[name] = widgets_values[idx]
|
||||
elif isinstance(node_inputs, dict) and not inputs:
|
||||
inputs = dict(node_inputs)
|
||||
return inputs
|
||||
|
||||
|
||||
def _ensure_saveimage_defaults(api_graph: dict[str, Any]) -> None:
|
||||
save_types = ("SaveImage", "Save Image", "Save Image (api)", "SaveImageNode")
|
||||
for node in api_graph.values():
|
||||
if not isinstance(node, dict) or node.get("class_type") not in save_types:
|
||||
continue
|
||||
inputs = node.get("inputs")
|
||||
if isinstance(inputs, dict) and not inputs.get("filename_prefix"):
|
||||
inputs["filename_prefix"] = "ComfyUI"
|
||||
|
||||
_NODE_MODE_DISABLED = 4
|
||||
|
||||
|
||||
def ui_to_api_format(workflow: dict[str, Any]) -> dict[str, Any]:
|
||||
workflow = _extract_graph(workflow)
|
||||
nodes = workflow.get("nodes")
|
||||
if not isinstance(nodes, list):
|
||||
if _is_api_format(workflow):
|
||||
return workflow
|
||||
return {}
|
||||
links = workflow.get("links")
|
||||
link_map = _build_link_map(links) if isinstance(links, list) else {}
|
||||
out: dict[str, Any] = {}
|
||||
for node in nodes:
|
||||
if not isinstance(node, dict):
|
||||
continue
|
||||
if node.get("mode") == _NODE_MODE_DISABLED:
|
||||
continue
|
||||
nid = node.get("id")
|
||||
if nid is None:
|
||||
continue
|
||||
node_id = str(nid)
|
||||
class_type = node.get("type") or node.get("class_type")
|
||||
if not class_type:
|
||||
continue
|
||||
inputs = _inputs_from_ui_node(node, class_type, link_map)
|
||||
meta = node.get("_meta")
|
||||
if not meta and isinstance(node.get("properties"), dict):
|
||||
s_r_name = (node.get("properties") or {}).get("Node name for S&R")
|
||||
if isinstance(s_r_name, str) and s_r_name.strip():
|
||||
meta = {"title": s_r_name.strip()}
|
||||
normalized: dict[str, Any] = {"class_type": class_type, "inputs": inputs}
|
||||
if meta:
|
||||
normalized["_meta"] = meta
|
||||
out[node_id] = normalized
|
||||
_ensure_saveimage_defaults(out)
|
||||
return out
|
||||
|
||||
|
||||
def load_workflow(workflow_id: str) -> tuple[str, dict[str, Any]]:
|
||||
wf_dir = get_workflows_directory()
|
||||
if not wf_dir:
|
||||
raise FileNotFoundError("Workflow directory not configured")
|
||||
safe_id = (workflow_id or "").strip()
|
||||
if not safe_id or "/" in safe_id or "\\" in safe_id or ".." in safe_id:
|
||||
raise ValueError("Invalid workflow id")
|
||||
path = (wf_dir / safe_id).with_suffix(".json")
|
||||
if not path.is_file():
|
||||
raise FileNotFoundError(f"Workflow not found: {safe_id}")
|
||||
try:
|
||||
raw = path.read_text(encoding="utf-8", errors="replace")
|
||||
data = json.loads(raw)
|
||||
except json.JSONDecodeError as e:
|
||||
raise ValueError(f"Invalid workflow JSON: {e}") from e
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError("Workflow must be a JSON object")
|
||||
name = data.get("name")
|
||||
if not isinstance(name, str) and isinstance(data.get("workflow"), dict):
|
||||
name = data["workflow"].get("name")
|
||||
if not isinstance(name, str) or not name.strip():
|
||||
name = safe_id.replace("_", " ").strip() or safe_id
|
||||
|
||||
graph = _extract_graph(data)
|
||||
if not graph or (isinstance(graph, dict) and not graph.get("nodes") and not _is_api_format(graph)):
|
||||
raise ValueError("Workflow has no executable nodes")
|
||||
return name, graph
|
||||
@@ -1,16 +1,3 @@
|
||||
"""
|
||||
WorkflowUILink: A ComfyUI custom node that defines which form fields WorkflowUI shows.
|
||||
|
||||
IMPORTANT - form_label: This is displayed as the header/section title above the inputs in the
|
||||
WorkflowUI app. This is the main text users see when filling out the form. Set it to describe
|
||||
your inputs (e.g. "Generation parameters", "Image settings").
|
||||
|
||||
Configuration:
|
||||
- form_label: Header shown above inputs in the WorkflowUI app (displayed as section title)
|
||||
- type_0..7: Dropdown to select field type per slot (text, number, seed, image, video, audio, boolean, select)
|
||||
- name_0..7: Field name and label shown in the app (use "seed" for master seed binding; reserved convention)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
@@ -29,12 +16,10 @@ except ImportError:
|
||||
|
||||
MAX_INPUT_SLOTS = 8
|
||||
|
||||
# Type options for the per-slot dropdown; empty = unused slot
|
||||
TYPE_OPTIONS = ["", "text", "number", "seed", "image", "video", "audio", "boolean", "select"]
|
||||
|
||||
|
||||
def _input_field_for_type(slot: int, typ: str) -> str:
|
||||
"""Map definition type to actual input field name for binding."""
|
||||
if typ == "image":
|
||||
return f"input_image_{slot}"
|
||||
if typ == "video":
|
||||
@@ -49,7 +34,6 @@ def _input_field_for_type(slot: int, typ: str) -> str:
|
||||
|
||||
|
||||
def _get_media_files(content_types: list[str]) -> list[str]:
|
||||
"""Get sorted list of filenames from input directory filtered by content type."""
|
||||
try:
|
||||
import folder_paths
|
||||
input_dir = folder_paths.get_input_directory()
|
||||
@@ -63,7 +47,6 @@ def _get_media_files(content_types: list[str]) -> list[str]:
|
||||
|
||||
|
||||
def _build_input_types() -> dict:
|
||||
"""Build INPUT_TYPES with title widgets and file pickers for image/video/audio."""
|
||||
try:
|
||||
import folder_paths
|
||||
input_dir = folder_paths.get_input_directory()
|
||||
@@ -87,7 +70,6 @@ def _build_input_types() -> dict:
|
||||
"placeholder": f"Name and label in app (e.g. 'Positive prompt'; use 'seed' for master seed)",
|
||||
})
|
||||
|
||||
# Ensure '' is always first so unused slots can send empty and pass validation
|
||||
image_opts = [""] + (image_files or [])
|
||||
video_opts = [""] + (video_files or [])
|
||||
audio_opts = [""] + (audio_files or [])
|
||||
@@ -132,7 +114,6 @@ def _build_return_names() -> tuple[str, ...]:
|
||||
|
||||
|
||||
def _load_image_from_path(image: str) -> Any:
|
||||
"""Load image from filename (ComfyUI input dir) and return IMAGE tensor [1,H,W,3]."""
|
||||
if not _HAS_IMAGE_DEPS or not image or not isinstance(image, str):
|
||||
return None
|
||||
try:
|
||||
@@ -162,7 +143,6 @@ def _empty_image_placeholder() -> Any:
|
||||
|
||||
|
||||
class WorkflowUILink:
|
||||
"""Pass-through node: defines app form inputs with per-slot labels and file pickers for media."""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls) -> dict:
|
||||
@@ -201,7 +181,6 @@ class WorkflowUILink:
|
||||
return tuple(results)
|
||||
|
||||
|
||||
# Registration
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"WorkflowUILink": WorkflowUILink,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user