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)
190 lines
6.1 KiB
Python
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)
|