Add a Workflows page that manages ComfyUI workflow JSON files as a first-class library alongside loras/checkpoints/embeddings: - opt-in via the new 'Workflow Management' settings toggle (default off) - dedicated workflow folder roots with sidecar/preview support, separate from ComfyUI's own workflow storage; 'Send to ComfyUI' loads a workflow straight into the graph - workflow scanner parses the graph document (UI/API format sniffing, node classes, model/civitai refs) and persists the analysis in the SQLite model cache - settings UI: workflow folder paths editor in the Library tab and the standalone Model Paths page Fixes from sandbox E2E verification: - include the workflow scanner in startup initialization so the page no longer hangs on 'initializing' when enabled without folders - add the missing workflows entry to the frontend page-state map (page init crashed and the sidebar fell back to the loras tree without it) - project workflow analysis fields through the scanner cache entry and persist them in the model cache (new columns with auto-migration) - keep symlinked paths intact in the directory picker (abspath instead of resolve), so stored library paths match what the user sees on disk Also rebuilds the vue-widgets bundle to sync it with web/comfyui/utils.js.
179 lines
5.7 KiB
Python
179 lines
5.7 KiB
Python
"""ComfyUI workflow JSON sniffing and analysis.
|
|
|
|
Shared by the workflow scanner (library indexing) and, eventually, the recipe
|
|
parsers: both need to recognize ComfyUI workflow documents and extract the
|
|
models and node classes they reference.
|
|
|
|
Two on-disk shapes exist:
|
|
|
|
- UI format (``File > Save`` in ComfyUI): ``{"nodes": [...], "links": [...], ...}``
|
|
- API format (queue prompt payload): ``{"<node_id>": {"class_type": ..., "inputs": {...}}}``
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
import re
|
|
from dataclasses import dataclass, field
|
|
from typing import Any, Dict, List, Optional, Set
|
|
|
|
from .constants import MODEL_FILE_EXTENSIONS
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
WORKFLOW_FORMAT_UI = "ui"
|
|
WORKFLOW_FORMAT_API = "api"
|
|
|
|
# Workflows are small JSON documents; anything larger is almost certainly not
|
|
# a graph (or is pathological) and is not worth parsing during a scan.
|
|
MAX_WORKFLOW_JSON_BYTES = 64 * 1024 * 1024
|
|
|
|
_CIVITAI_URN_PATTERN = re.compile(r"civitai:(\d+)@(\d+)")
|
|
|
|
# Loader input keys whose string values name a model file. Used for the API
|
|
# format; the UI format is handled by the generic extension scan below.
|
|
_LOADER_INPUT_KEYS = frozenset(
|
|
{
|
|
"lora_name",
|
|
"ckpt_name",
|
|
"unet_name",
|
|
"vae_name",
|
|
"clip_name",
|
|
"control_net_name",
|
|
"embedding_name",
|
|
"model_name",
|
|
}
|
|
)
|
|
|
|
|
|
@dataclass
|
|
class WorkflowAnalysis:
|
|
"""Structured facts extracted from a ComfyUI workflow document."""
|
|
|
|
workflow_format: str
|
|
node_count: int = 0
|
|
node_classes: List[str] = field(default_factory=list)
|
|
model_refs: List[str] = field(default_factory=list)
|
|
civitai_refs: List[str] = field(default_factory=list)
|
|
|
|
|
|
def _sniff(data: Any) -> Optional[str]:
|
|
"""Return the workflow format for a parsed JSON document, or None."""
|
|
if not isinstance(data, dict):
|
|
return None
|
|
nodes = data.get("nodes")
|
|
links = data.get("links")
|
|
if isinstance(nodes, list) and nodes and isinstance(links, list):
|
|
return WORKFLOW_FORMAT_UI
|
|
# API format: a non-empty mapping of node id -> {class_type, inputs}.
|
|
if data and all(
|
|
isinstance(value, dict) and "class_type" in value for value in data.values()
|
|
):
|
|
return WORKFLOW_FORMAT_API
|
|
return None
|
|
|
|
|
|
def sniff_workflow_text(text: str) -> Optional[str]:
|
|
"""Sniff raw JSON text; returns the workflow format or None."""
|
|
try:
|
|
data = json.loads(text)
|
|
except (TypeError, ValueError):
|
|
return None
|
|
return _sniff(data)
|
|
|
|
|
|
def load_workflow_file(file_path: str) -> Optional[Dict[str, Any]]:
|
|
"""Load and sniff a workflow JSON file.
|
|
|
|
Returns the parsed document when it is a recognized ComfyUI workflow,
|
|
None otherwise (unreadable, oversized, invalid JSON, or not a workflow).
|
|
"""
|
|
try:
|
|
with open(file_path, "r", encoding="utf-8") as handle:
|
|
text = handle.read(MAX_WORKFLOW_JSON_BYTES + 1)
|
|
except (OSError, UnicodeDecodeError) as exc:
|
|
logger.debug("Workflow file unreadable: %s (%s)", file_path, exc)
|
|
return None
|
|
if len(text) > MAX_WORKFLOW_JSON_BYTES:
|
|
return None
|
|
try:
|
|
data = json.loads(text)
|
|
except ValueError:
|
|
return None
|
|
if _sniff(data) is None:
|
|
return None
|
|
return data
|
|
|
|
|
|
def _walk_strings(value: Any, out: List[str], depth: int = 0) -> None:
|
|
"""Collect every string scalar in a JSON structure (bounded depth)."""
|
|
if depth > 12 or len(out) > 10000:
|
|
return
|
|
if isinstance(value, str):
|
|
out.append(value)
|
|
elif isinstance(value, dict):
|
|
for item in value.values():
|
|
_walk_strings(item, out, depth + 1)
|
|
elif isinstance(value, (list, tuple)):
|
|
for item in value:
|
|
_walk_strings(item, out, depth + 1)
|
|
|
|
|
|
def analyze_workflow(data: Dict[str, Any]) -> Optional[WorkflowAnalysis]:
|
|
"""Analyze a parsed workflow document. None when not a workflow."""
|
|
workflow_format = _sniff(data)
|
|
if workflow_format is None:
|
|
return None
|
|
|
|
node_classes: Set[str] = set()
|
|
node_count = 0
|
|
|
|
if workflow_format == WORKFLOW_FORMAT_API:
|
|
node_count = len(data)
|
|
for node in data.values():
|
|
class_type = node.get("class_type")
|
|
if isinstance(class_type, str) and class_type:
|
|
node_classes.add(class_type)
|
|
else:
|
|
nodes = data.get("nodes") or []
|
|
node_count = len(nodes)
|
|
for node in nodes:
|
|
if not isinstance(node, dict):
|
|
continue
|
|
class_type = node.get("type")
|
|
if isinstance(class_type, str) and class_type:
|
|
node_classes.add(class_type)
|
|
|
|
strings: List[str] = []
|
|
_walk_strings(data, strings)
|
|
|
|
model_refs: Set[str] = set()
|
|
civitai_refs: Set[str] = set()
|
|
for value in strings:
|
|
for match in _CIVITAI_URN_PATTERN.finditer(value):
|
|
civitai_refs.add(f"civitai:{match.group(1)}@{match.group(2)}")
|
|
lowered = value.lower()
|
|
if any(lowered.endswith(ext) for ext in MODEL_FILE_EXTENSIONS):
|
|
# Widget values are single model filenames; skip strings that
|
|
# clearly carry more (URLs, prompts with punctuation-heavy tails
|
|
# still end in an extension, so also bound the length).
|
|
if len(value) <= 260 and " " not in value.strip():
|
|
model_refs.add(value.strip())
|
|
|
|
return WorkflowAnalysis(
|
|
workflow_format=workflow_format,
|
|
node_count=node_count,
|
|
node_classes=sorted(node_classes),
|
|
model_refs=sorted(model_refs),
|
|
civitai_refs=sorted(civitai_refs),
|
|
)
|
|
|
|
|
|
def analyze_workflow_file(file_path: str) -> Optional[WorkflowAnalysis]:
|
|
"""Load + analyze a workflow file; None when it is not a workflow."""
|
|
data = load_workflow_file(file_path)
|
|
if data is None:
|
|
return None
|
|
return analyze_workflow(data)
|