Files
willmiao-ComfyUI-Lora-Manager/py/utils/workflow_analysis.py
T
Will Miao 7d071ab3aa feat(workflows): accept zip/png/webp artifacts with derived workflow documents
Library entries are the original artifact bytes (SHA256 = CivitAI identity);
container artifacts (.zip, .png, .webp) get their extracted workflow document
materialized as a *.workflow.json sidecar that follows rename/move/delete.

- workflow_extraction: json direct-read, zip candidate pick, png/webp metadata
- scanner whitelist extended; non-workflow files rejected; adjust_metadata
  fills analysis fields for downloaded entries; image artifacts self-preview
- download_manager: workflow branch end-to-end, no zip unpacking, file
  selection prefers primary then json/zip/image
- lifecycle: delete/rename/pending-delete carry the derived document
- send-to-comfy reads the derived document for container artifacts
- fix: force_refresh with no cache falls back to full initialization
  (first scan before deferred background init no longer crashes)
2026-10-28 16:47:11 +08:00

190 lines
6.1 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 parse_workflow_text(text: str) -> Optional[Dict[str, Any]]:
"""Parse + sniff raw JSON text; return the document when it is a workflow."""
try:
data = json.loads(text)
except (TypeError, ValueError):
return None
if _sniff(data) is None:
return None
return 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)