Files

2115 lines
90 KiB
Python

"""Metadata extraction adapted from ComfyUI Image Browser v1.6.0.
Standalone: no ComfyUI imports or running server required.
"""
import hashlib
import json
import math
import os
import re
import shutil
import subprocess
import tempfile
from itertools import islice
from pathlib import Path
try:
from PIL import Image, ExifTags
except ImportError:
Image = None
ExifTags = None
WORKFLOW_MAX_NODES = 5_000
WORKFLOW_MAX_LINKS = 20_000
WORKFLOW_MAX_SUBGRAPHS = 128
WORKFLOW_MAX_PORTS = 512
WORKFLOW_MAX_TOTAL_PORTS = 40_000
WORKFLOW_MAX_PARAMS = 64
WORKFLOW_MAX_TEXT_LENGTH = 2_048
WORKFLOW_MAX_PROMPT_LENGTH = 20_000
WORKFLOW_MAX_ID_LENGTH = 512
WORKFLOW_MAX_LORAS = 128
WORKFLOW_RAW_SCAN_BYTES = 16 * 1024 * 1024
WORKFLOW_MAX_EMBEDDED_JSON_BYTES = 4 * 1024 * 1024
WORKFLOW_MAX_RAW_DISPLAY_BYTES = 8 * 1024 * 1024
WORKFLOW_MAX_SCAN_CANDIDATES = 32
WORKFLOW_MAX_METADATA_ENTRIES = 256
WORKFLOW_MAX_RESOLVE_STEPS = 80_000
WORKFLOW_MAX_RESOLVER_CACHE = 512
_RESOLVER_MEMO_KEY = object()
def _safe_str(value):
if value is None:
return ""
if isinstance(value, bytes):
try:
return value.decode("utf-8", errors="ignore")
except Exception:
return f"<{len(value)} bytes>"
return str(value)
def _finite_json_value(value, depth=0):
if isinstance(value, float):
return value if math.isfinite(value) else None
if isinstance(value, (str, int, bool)) or value is None:
return value
if depth >= 8:
return _workflow_text(value)
if isinstance(value, dict):
return {
_workflow_text(key): _finite_json_value(item, depth + 1)
for key, item in islice(value.items(), 256)
}
if isinstance(value, (list, tuple)):
return [_finite_json_value(item, depth + 1) for item in islice(value, 256)]
return _workflow_text(value)
def raw_metadata_for_display(metadata):
"""Return bounded, strict-JSON metadata while retaining workflow and prompt trees."""
remaining_text = [WORKFLOW_MAX_RAW_DISPLAY_BYTES]
def bounded_text(value):
candidate = value[:WORKFLOW_MAX_PROMPT_LENGTH]
encoded = candidate.encode("utf-8", errors="ignore")
if len(encoded) <= remaining_text[0]:
remaining_text[0] -= len(encoded)
return candidate
prefix = encoded[:remaining_text[0]].decode("utf-8", errors="ignore")
remaining_text[0] -= len(prefix.encode("utf-8"))
return prefix
def clean(value, depth=0):
if isinstance(value, float):
return value if math.isfinite(value) else None
if isinstance(value, str):
return bounded_text(value)
if isinstance(value, bytes):
return clean(value.decode("utf-8", errors="ignore"), depth)
if isinstance(value, (int, bool)) or value is None:
return value
if depth >= 12:
return clean(_workflow_text(value), depth)
if isinstance(value, dict):
result = {}
priority = ("CreationTime", "prompt", "workflow", "parameters") if depth == 0 else ()
if priority:
for key in priority:
if key in value and len(result) < WORKFLOW_MAX_METADATA_ENTRIES:
result[key] = clean(value[key], depth + 1)
for key, item in value.items():
if key in priority or len(result) >= WORKFLOW_MAX_METADATA_ENTRIES:
continue
if remaining_text[0] <= 0:
break
result[_workflow_text(key)] = clean(item, depth + 1)
return result
if isinstance(value, (list, tuple)):
result = []
for item in islice(value, WORKFLOW_MAX_METADATA_ENTRIES):
if remaining_text[0] <= 0:
break
result.append(clean(item, depth + 1))
return result
return clean(_workflow_text(value), depth)
return clean(metadata)
def _workflow_text(value):
def preview(item, depth=0):
if item is None:
return ""
if isinstance(item, str):
return item[:WORKFLOW_MAX_TEXT_LENGTH]
if isinstance(item, bytes):
return item[:WORKFLOW_MAX_TEXT_LENGTH].decode("utf-8", errors="ignore")
if isinstance(item, dict):
if depth >= 2:
return f"<{len(item)} values>"
parts = [
f"{preview(key, depth + 1)[:64]}: {preview(nested, depth + 1)}"
for key, nested in islice(item.items(), 8)
]
if len(item) > 8:
parts.append(f"… {len(item) - 8} more")
return "{" + ", ".join(parts) + "}"
if isinstance(item, (list, tuple, set)):
if depth >= 2:
return f"<{len(item)} values>"
parts = [preview(nested, depth + 1) for nested in islice(iter(item), 8)]
if len(item) > 8:
parts.append(f"… {len(item) - 8} more")
return "[" + ", ".join(parts) + "]"
return _safe_str(item)[:WORKFLOW_MAX_TEXT_LENGTH]
return preview(value)[:WORKFLOW_MAX_TEXT_LENGTH]
def _workflow_id(value):
text = value if isinstance(value, str) else _workflow_text(value)
if len(text) <= WORKFLOW_MAX_ID_LENGTH:
return text
sample = f"{text[:4096]}|{text[-256:]}|{len(text)}"
digest = hashlib.sha256(sample.encode("utf-8", errors="ignore")).hexdigest()
keep = WORKFLOW_MAX_ID_LENGTH - len(digest) - 1
return f"{text[:keep]}~{digest}"
def extract_metadata(image_path):
"""Extract metadata from image (PNG/JPEG/WEBP/etc)"""
try:
if not Image or not image_path.exists():
return {}
with Image.open(image_path) as img:
metadata = {}
# Extract PNG text chunks (ComfyUI stores workflow here)
if hasattr(img, 'text'):
for key, value in islice(img.text.items(), WORKFLOW_MAX_METADATA_ENTRIES):
metadata[key] = value
# Also check PNG info dict
if hasattr(img, 'info'):
for key, value in islice(img.info.items(), WORKFLOW_MAX_METADATA_ENTRIES):
if key not in metadata:
metadata[key] = value
# EXIF (JPEG/WEBP/etc)
exif_data = {}
try:
exif = img._getexif() if hasattr(img, "_getexif") else None
if exif:
for tag, val in islice(exif.items(), WORKFLOW_MAX_METADATA_ENTRIES):
name = ExifTags.TAGS.get(tag, str(tag)) if Image else str(tag)
exif_data[name] = val
except Exception:
pass
if exif_data:
metadata["EXIF"] = exif_data
# XMP (if present)
if "XML:com.adobe.xmp" in metadata:
metadata["XMP"] = metadata.get("XML:com.adobe.xmp")
elif "xmp" in metadata:
metadata["XMP"] = metadata.get("xmp")
# If ICC profile exists, include a summary only
if "icc_profile" in metadata:
icc = metadata.get("icc_profile")
metadata["ICC Profile"] = f"<{len(icc)} bytes>" if isinstance(icc, (bytes, bytearray)) else _safe_str(icc)
# Try to decode 'prompt' and 'workflow' if they are JSON strings
for key in ['prompt', 'workflow']:
if key in metadata and isinstance(metadata[key], str):
try:
if len(metadata[key]) <= WORKFLOW_MAX_EMBEDDED_JSON_BYTES:
metadata[key] = json.loads(metadata[key])
except:
pass
return metadata
except Exception as e:
print(f"Error extracting metadata from {image_path}: {e}")
return {}
def _validate_and_get_workflow(json_string):
try:
data = json.loads(json_string)
workflow_data = data.get('workflow', data.get('prompt', data))
if isinstance(workflow_data, dict):
if 'nodes' in workflow_data:
return workflow_data, 'ui'
is_api = False
for _, value in workflow_data.items():
if isinstance(value, dict) and 'class_type' in value:
is_api = True
break
if is_api:
return workflow_data, 'api'
except Exception:
pass
return None, None
def _scan_bytes_for_workflow(content_bytes):
"""
Yield valid JSON object strings found in a binary stream by brace matching.
"""
try:
stream_str = bytes(content_bytes[:WORKFLOW_RAW_SCAN_BYTES]).decode('utf-8', errors='ignore')
except Exception:
return
start_pos = 0
candidates_examined = 0
while candidates_examined < WORKFLOW_MAX_SCAN_CANDIDATES:
first_brace = stream_str.find('{', start_pos)
if first_brace == -1:
break
open_braces = 0
start_index = first_brace
in_string = False
escaped = False
for i in range(start_index, len(stream_str)):
char = stream_str[i]
if in_string:
if escaped:
escaped = False
elif char == '\\':
escaped = True
elif char == '"':
in_string = False
continue
if char == '"':
in_string = True
elif char == '{':
open_braces += 1
elif char == '}':
open_braces -= 1
if open_braces == 0:
candidate = stream_str[start_index:i + 1]
candidates_examined += 1
try:
json.loads(candidate)
yield candidate
start_pos = i + 1
except Exception:
start_pos = start_index + 1
break
if i - start_index + 1 > WORKFLOW_MAX_EMBEDDED_JSON_BYTES:
candidates_examined += 1
start_pos = start_index + 1
break
else:
candidates_examined += 1
start_pos = start_index + 1
def extract_workflow_payloads_from_file(file_path):
"""
Extract workflow payload from image/video/audio files.
Return every embedded ComfyUI payload found, keyed by ``ui`` and/or ``api``.
"""
found = {}
def analyze_json(json_str):
wf, wf_type = _validate_and_get_workflow(json_str)
if wf and wf_type and wf_type not in found:
found[wf_type] = wf
ext_lower = str(file_path).lower()
is_media = ext_lower.endswith(('.mp4', '.mkv', '.webm', '.mov', '.avi', '.mp3', '.wav', '.ogg', '.flac', '.m4a', '.aac'))
# Video/audio metadata tags via ffprobe (primary path for many encoded files)
if is_media:
ffprobe_bin = os.environ.get('FFPROBE_PATH') or shutil.which('ffprobe')
if ffprobe_bin:
try:
cmd = [ffprobe_bin, '-v', 'quiet', '-print_format', 'json', '-show_format', str(file_path)]
creationflags = subprocess.CREATE_NO_WINDOW if os.name == 'nt' else 0
with tempfile.TemporaryFile() as output:
subprocess.run(
cmd,
stdout=output,
stderr=subprocess.DEVNULL,
check=True,
timeout=15,
creationflags=creationflags,
)
output.seek(0)
ff_output = output.read(WORKFLOW_RAW_SCAN_BYTES)
ff_data = json.loads(ff_output.decode('utf-8', errors='ignore'))
tags = ff_data.get('format', {}).get('tags', {})
if isinstance(tags, dict):
for value in islice(tags.values(), WORKFLOW_MAX_METADATA_ENTRIES):
if not isinstance(value, str):
continue
value = value[:WORKFLOW_MAX_EMBEDDED_JSON_BYTES]
if value.strip().startswith('{'):
analyze_json(value)
elif '{' in value:
for json_str in _scan_bytes_for_workflow(value.encode('utf-8', errors='ignore')):
analyze_json(json_str)
except Exception:
pass
# Fast image metadata path (PNG/JPG/WEBP text chunks, EXIF)
if Image:
try:
with Image.open(file_path) as img:
for key in ['workflow', 'prompt']:
value = img.info.get(key)
if isinstance(value, str) and value.strip().startswith('{'):
analyze_json(value)
exif_data = img.info.get('exif')
if isinstance(exif_data, bytes):
for json_str in _scan_bytes_for_workflow(exif_data):
analyze_json(json_str)
except Exception:
pass
# Ultimate fallback: scan raw bytes (works for video/audio metadata blobs too)
if not found:
try:
with open(file_path, 'rb') as f:
window = WORKFLOW_RAW_SCAN_BYTES // 2
size = os.fstat(f.fileno()).st_size
head = f.read(window)
tail = b''
if size > window:
f.seek(max(window, size - window))
tail = f.read(window)
for content in (head, tail):
if not content:
continue
for json_str in _scan_bytes_for_workflow(content):
analyze_json(json_str)
if 'ui' in found and 'api' in found:
break
if 'ui' in found and 'api' in found:
break
except Exception:
pass
return found
def extract_workflow_from_file(file_path):
"""Return the preferred workflow payload while preserving the legacy API."""
found = extract_workflow_payloads_from_file(file_path)
if 'ui' in found:
return found['ui'], 'ui'
if 'api' in found:
return found['api'], 'api'
return None, None
def extract_media_dimensions(file_path):
"""Read the encoded dimensions of the first video stream with ffprobe."""
ffprobe_bin = os.environ.get('FFPROBE_PATH') or shutil.which('ffprobe')
if not ffprobe_bin:
return {'width': None, 'height': None}
try:
cmd = [
ffprobe_bin, '-v', 'quiet', '-print_format', 'json',
'-select_streams', 'v:0', '-show_entries', 'stream=width,height',
str(file_path),
]
creationflags = subprocess.CREATE_NO_WINDOW if os.name == 'nt' else 0
with tempfile.TemporaryFile() as output:
subprocess.run(
cmd,
stdout=output,
stderr=subprocess.DEVNULL,
check=True,
timeout=15,
creationflags=creationflags,
)
output.seek(0)
probe = json.loads(output.read(64 * 1024).decode('utf-8', errors='ignore'))
streams = probe.get('streams', [])
stream = streams[0] if isinstance(streams, list) and streams else {}
width = stream.get('width') if isinstance(stream, dict) else None
height = stream.get('height') if isinstance(stream, dict) else None
return {
'width': width if isinstance(width, int) and width > 0 else None,
'height': height if isinstance(height, int) and height > 0 else None,
}
except Exception:
return {'width': None, 'height': None}
def _normalized_lora_identity(name):
value = _workflow_text(name).strip().replace('\\', '/')
for suffix in ('.safetensors', '.ckpt', '.pt', '.bin'):
if value.lower().endswith(suffix):
value = value[:-len(suffix)]
break
return value.casefold()
def _normalized_lora_basename(name):
return _normalized_lora_identity(name).rsplit('/', 1)[-1]
def _lora_names_equivalent(first, second):
return _normalized_lora_identity(first) == _normalized_lora_identity(second)
def _parameter_lora_hash_names(text):
"""Return LoRA names explicitly recorded as applied by a parameters exporter."""
names = set()
lora_hashes = re.search(r'\bLora hashes:\s*"([^"\n]+)', text, re.IGNORECASE)
if lora_hashes:
for pair in islice(lora_hashes.group(1).split(','), WORKFLOW_MAX_LORAS):
if ':' in pair:
names.add(_normalized_lora_identity(pair.rsplit(':', 1)[0]))
hashes_marker = re.search(r'\bHashes:\s*', text, re.IGNORECASE)
if hashes_marker:
try:
payload, _ = json.JSONDecoder().raw_decode(text[hashes_marker.end():])
if isinstance(payload, dict):
for key in islice(payload, WORKFLOW_MAX_LORAS * 4):
key_text = _workflow_text(key).strip()
if key_text.lower().startswith('lora:'):
names.add(_normalized_lora_identity(key_text.split(':', 1)[1]))
except (TypeError, ValueError, json.JSONDecodeError):
pass
return {name for name in names if name}
def _parse_parameters_text(params_text, parsed):
if not params_text or not isinstance(params_text, str):
return
text = params_text[:WORKFLOW_MAX_PROMPT_LENGTH].strip()
if not text:
return
# Positive prompt (explicit label, if present)
pos_match = re.search(
r'(?:^|\n)Positive prompt:\s*(.+?)(?=\n(?:Negative prompt:|Steps:|Seed:|Sampler:|CFG(?: scale)?:|CfgScale:|Size:|Model:|Scheduler:|$))',
text,
re.DOTALL,
)
if pos_match and not parsed['prompt']:
parsed['prompt'] = pos_match.group(1).strip()
# Fallback: extract unlabeled positive prompt block (common in A1111/Forge parameters)
if not parsed['prompt']:
marker_match = re.search(
r'(?m)^\s*(Negative prompt:|Steps:|Seed:|Sampler:|CFG(?: scale)?:|CfgScale:|Size:|Model:|Scheduler:)',
text,
)
prompt_block = text[:marker_match.start()] if marker_match else text
prompt_block = prompt_block.strip()
first_line = prompt_block.split('\n', 1)[0].strip() if prompt_block else ''
if prompt_block and not re.search(
r'^(Steps:|Seed:|Sampler:|CFG(?: scale)?:|CfgScale:|Size:|Model:|Scheduler:)',
first_line,
):
parsed['prompt'] = prompt_block
# Negative prompt
neg_match = re.search(
r'(?:^|\n)Negative prompt:\s*(.+?)(?=\n(?:Steps:|Seed:|Sampler:|CFG(?: scale)?:|CfgScale:|Size:|Model:|Scheduler:|$))',
text,
re.DOTALL,
)
if neg_match and not parsed['negative_prompt']:
parsed['negative_prompt'] = neg_match.group(1).strip()
# Standard parameters
steps_match = re.search(r'Steps:\s*(\d+)', text)
if steps_match and not parsed['steps']:
parsed['steps'] = int(steps_match.group(1))
sampler_match = re.search(r'Sampler:\s*([^,\n]+)', text)
if sampler_match and not parsed['sampler']:
parsed['sampler'] = sampler_match.group(1).strip()
scheduler_match = re.search(r'Scheduler:\s*([^,\n]+)', text)
if scheduler_match and not parsed['scheduler']:
parsed['scheduler'] = scheduler_match.group(1).strip()
cfg_match = re.search(r'CFG scale:\s*([\d.]+)', text) or re.search(r'CfgScale:\s*([\d.]+)', text)
if cfg_match and not parsed['cfg']:
parsed['cfg'] = float(cfg_match.group(1))
seed_match = re.search(r'Seed:\s*(\d+)', text)
if seed_match and not parsed['seed']:
parsed['seed'] = int(seed_match.group(1))
size_match = re.search(r'Size:\s*(\d+)x(\d+)', text)
if size_match and not parsed['width'] and not parsed['height']:
parsed['width'] = int(size_match.group(1))
parsed['height'] = int(size_match.group(2))
model_match = re.search(r'Model:\s*([^,\n]+)', text)
if model_match and not parsed['model']:
parsed['model'] = model_match.group(1).strip()
# Extra fields
extra_fields = {
"VAE": r'VAE:\s*([^,\n]+)',
"VAE hash": r'VAE hash:\s*([^,\n]+)',
"Denoising strength": r'Denoising strength:\s*([\d.]+)',
"Hires steps": r'Hires steps:\s*([^,\n]+)',
"Hires upscale": r'Hires upscale:\s*([^,\n]+)',
"Hires resize": r'Hires resize:\s*([^,\n]+)',
"Refiner": r'Refiner:\s*([^,\n]+)',
"Refiner switch": r'Refiner switch:\s*([^,\n]+)',
"RNG": r'RNG:\s*([^,\n]+)'
}
for label, pattern in extra_fields.items():
m = re.search(pattern, text)
if m:
parsed['extras'][label] = m.group(1).strip()
# A <lora:...> token in ComfyUI can remain after the LoRA is disabled. The
# exporter's applied-hash payload is the authoritative distinction for
# parameters-only files.
is_comfyui_parameters = bool(re.search(r'\bVersion:\s*ComfyUI\b', text, re.IGNORECASE))
applied_hash_names = _parameter_lora_hash_names(text)
lora_tags = re.findall(r'<lora:([^:>]+):([\d.]+)>', text)
for lora_name, strength in lora_tags:
lora_name = lora_name.strip()
if applied_hash_names:
identity = _normalized_lora_identity(lora_name)
if identity not in applied_hash_names:
basename_matches = {
applied for applied in applied_hash_names
if '/' not in identity and _normalized_lora_basename(applied) == identity
}
if len(basename_matches) != 1:
continue
elif is_comfyui_parameters:
continue
_add_lora(parsed, lora_name, float(strength), float(strength))
def _sanitize_prompt_text(prompt_text):
"""Remove inline LoRA tags from display prompt while preserving line structure."""
if not isinstance(prompt_text, str):
return prompt_text
prompt_text = prompt_text[:WORKFLOW_MAX_PROMPT_LENGTH]
cleaned = re.sub(r'[ \t]*<lora:[^>]+>[ \t]*', ' ', prompt_text, flags=re.IGNORECASE)
out_lines = []
for line in cleaned.splitlines():
line = re.sub(r'[ \t]+', ' ', line).strip()
line = re.sub(r'\s+,', ',', line)
line = re.sub(r',\s*,', ',', line)
if line:
out_lines.append(line)
return '\n'.join(out_lines).strip()
def _decode_json_maybe(value):
if isinstance(value, str):
try:
return json.loads(value)
except Exception:
return value
return value
def _is_link_ref(value):
if not isinstance(value, (list, tuple)) or len(value) != 2:
return False
node_id, slot = value
return (
isinstance(node_id, (str, int))
and not isinstance(node_id, bool)
and isinstance(slot, int)
and not isinstance(slot, bool)
and slot >= 0
)
def _node_title(node):
if not isinstance(node, dict):
return ''
meta = node.get('_meta') or {}
if not isinstance(meta, dict):
meta = {}
return str(node.get('title') or meta.get('title') or '')
def _node_type(node):
if not isinstance(node, dict):
return ''
return str(node.get('class_type') or node.get('type') or '')
def _iter_ui_workflow_nodes(workflow_data):
"""Yield (node, id_prefix, subgraph_name) for top-level and definitions.subgraphs UI nodes."""
if not isinstance(workflow_data, dict):
return
yielded = 0
for node in islice(workflow_data.get('nodes', []) or [], WORKFLOW_MAX_NODES):
if isinstance(node, dict):
yield node, '', None
yielded += 1
definitions = workflow_data.get('definitions')
if not isinstance(definitions, dict):
return
raw_subgraphs = definitions.get('subgraphs', [])
if not isinstance(raw_subgraphs, list):
return
used_subgraph_ids = set()
for subgraph_index, subgraph in enumerate(islice(raw_subgraphs, WORKFLOW_MAX_SUBGRAPHS)):
if yielded >= WORKFLOW_MAX_NODES:
break
if not isinstance(subgraph, dict):
continue
base_id = _workflow_id(subgraph.get('id') or subgraph.get('name') or f'subgraph-{subgraph_index + 1}')
subgraph_id = base_id
suffix = 2
while subgraph_id in used_subgraph_ids:
subgraph_id = _workflow_id(f'{base_id}~{suffix}')
suffix += 1
used_subgraph_ids.add(subgraph_id)
subgraph_name = _workflow_text(subgraph.get('name') or subgraph_id)
remaining = WORKFLOW_MAX_NODES - yielded
for node in islice(subgraph.get('nodes', []) or [], remaining):
if isinstance(node, dict):
yield node, f'{subgraph_id}:', subgraph_name
yielded += 1
def _coerce_number(value):
if isinstance(value, bool):
return None
if isinstance(value, (int, float)):
return value
if isinstance(value, str):
text = value.strip()
if re.fullmatch(r'-?\d+', text):
return int(text)
if re.fullmatch(r'-?\d+\.\d+', text):
return float(text)
return None
def _build_showtext_snapshots(prompt_graph):
snapshots = {}
for consumer in islice(prompt_graph.values(), WORKFLOW_MAX_NODES):
if not isinstance(consumer, dict) or 'showtext' not in _node_type(consumer).lower():
continue
consumer_inputs = consumer.get('inputs', {})
if not isinstance(consumer_inputs, dict):
continue
snapshot = next((
consumer_inputs[name].strip()
for name in islice(consumer_inputs, WORKFLOW_MAX_PORTS)
if str(name).startswith('text_')
and isinstance(consumer_inputs.get(name), str)
and consumer_inputs[name].strip()
), None)
if not snapshot:
continue
for input_value in islice(consumer_inputs.values(), WORKFLOW_MAX_PORTS):
if _is_link_ref(input_value):
snapshots.setdefault(str(input_value[0]), snapshot)
return snapshots
def _bounded_join_text(parts, delimiter=''):
delimiter = delimiter if isinstance(delimiter, str) else ''
delimiter = delimiter[:WORKFLOW_MAX_TEXT_LENGTH]
pieces = []
remaining = WORKFLOW_MAX_PROMPT_LENGTH
for part in parts:
if not isinstance(part, str) or not part.strip() or remaining <= 0:
continue
part = part.strip()
if pieces and delimiter:
separator = delimiter[:remaining]
pieces.append(separator)
remaining -= len(separator)
if remaining <= 0:
break
piece = part[:remaining]
pieces.append(piece)
remaining -= len(piece)
return ''.join(pieces).strip() or None
def _resolve_api_scalar(prompt_graph, value, visited=None):
"""Resolve a primitive value through generic ComfyUI helper nodes."""
seen = set(visited or ())
current = value
for _ in range(WORKFLOW_MAX_NODES):
if not _is_link_ref(current):
return current
node_key = str(current[0])
if node_key in seen:
return None
seen.add(node_key)
node = prompt_graph.get(node_key)
inputs = node.get('inputs', {}) if isinstance(node, dict) else {}
if not isinstance(inputs, dict):
return None
current = next((inputs[name] for name in ('value', 'boolean', 'switch', 'index', 'number') if name in inputs), None)
return None
def _resolve_api_text_iterative(
prompt_graph, value, preferred_names=None, visited=None, showtext_snapshots=None,
):
preferred_names = list(preferred_names or [])
snapshots = showtext_snapshots
if snapshots is None:
snapshots = _build_showtext_snapshots(prompt_graph)
memo = snapshots.setdefault(_RESOLVER_MEMO_KEY, {})
active = set(visited or ())
results = []
stack = [('eval', value)]
steps = 0
def cache_result(node_key, result):
cache_key = (node_key, tuple(preferred_names))
if result is None:
return
if cache_key not in memo and len(memo) >= WORKFLOW_MAX_RESOLVER_CACHE:
memo.pop(next(iter(memo)))
memo[cache_key] = result
while stack and steps < WORKFLOW_MAX_RESOLVE_STEPS:
steps += 1
frame = stack.pop()
operation = frame[0]
if operation == 'finish':
_, node_key, count, delimiter = frame
parts = results[-count:] if count else []
if count:
del results[-count:]
result = _bounded_join_text(parts, delimiter)
active.discard(node_key)
cache_result(node_key, result)
results.append(result)
continue
if operation == 'forward':
node_key = frame[1]
result = results[-1] if results else None
active.discard(node_key)
cache_result(node_key, result)
continue
current = frame[1]
if not _is_link_ref(current):
result = current.strip()[:WORKFLOW_MAX_PROMPT_LENGTH] if isinstance(current, str) else None
results.append(result or None)
continue
node_key = str(current[0])
cache_key = (node_key, tuple(preferred_names))
if cache_key in memo:
results.append(memo[cache_key])
continue
if node_key in active:
results.append(None)
continue
if node_key in snapshots:
result = snapshots[node_key][:WORKFLOW_MAX_PROMPT_LENGTH]
cache_result(node_key, result)
results.append(result)
continue
ref_node = prompt_graph.get(node_key)
if not isinstance(ref_node, dict):
results.append(None)
continue
inputs = ref_node.get('inputs', {})
if not isinstance(inputs, dict):
results.append(None)
continue
bounded_inputs = dict(islice(inputs.items(), WORKFLOW_MAX_PORTS))
active.add(node_key)
node_type_l = _node_type(ref_node).lower()
if 'switch' in node_type_l:
switch_value = _resolve_api_scalar(prompt_graph, bounded_inputs.get('switch'), active)
if isinstance(switch_value, str):
switch_value = switch_value.strip().lower() in {'true', '1', 'yes', 'on'}
branch_names = (
('on_true', 'true', 'if_true') if bool(switch_value)
else ('on_false', 'false', 'if_false')
)
selected = next((bounded_inputs[name] for name in branch_names if name in bounded_inputs), None)
if selected is not None:
stack.append(('forward', node_key))
stack.append(('eval', selected))
continue
if 'stringconcatenate' in node_type_l:
operands = [
bounded_inputs[name]
for name in sorted(bounded_inputs, key=str)
if str(name).startswith('string_')
]
stack.append(('finish', node_key, len(operands), bounded_inputs.get('delimiter', '')))
for operand in reversed(operands):
stack.append(('eval', operand))
continue
candidate_names = preferred_names + [
'text', 'prompt', 'positive', 'negative', 'string', 'string_a', 'value', 'name'
]
selected = next((bounded_inputs[name] for name in candidate_names if name in bounded_inputs), None)
if selected is None:
# Do not walk arbitrary model/clip/vae inputs when a conditioning node has
# no literal prompt. That used to display checkpoint filenames as prompts
# for upscalers such as SeedVR2. Only generic pass-through helpers may use
# their first input as a text source.
is_text_passthrough = any(token in node_type_l for token in (
'reroute', 'primitive', 'string', 'text', 'prompt', 'showtext', 'switch'
))
if not is_text_passthrough:
active.discard(node_key)
cache_result(node_key, None)
results.append(None)
continue
selected = next(iter(bounded_inputs.values()), None)
stack.append(('forward', node_key))
stack.append(('eval', selected))
if stack or not results:
return None
return results[-1]
def _resolve_api_value(
prompt_graph, value, preferred_names=None, want='text', visited=None,
showtext_snapshots=None,
):
"""Resolve a ComfyUI API graph input, following links through text/prompt/string/size helper nodes."""
preferred_names = preferred_names or []
if want == 'text':
return _resolve_api_text_iterative(
prompt_graph, value, preferred_names, visited, showtext_snapshots,
)
seen = set(visited or ())
current = value
if not _is_link_ref(current):
if want == 'number':
return _coerce_number(current)
if isinstance(current, str):
return current.strip()
return current if current is not None and want != 'text' else None
if showtext_snapshots is None:
showtext_snapshots = _build_showtext_snapshots(prompt_graph) if want == 'text' else {}
for _ in range(WORKFLOW_MAX_NODES):
if not _is_link_ref(current):
if want == 'number':
return _coerce_number(current)
if isinstance(current, str):
return current.strip()
return current if current is not None and want != 'text' else None
node_key = str(current[0])
if node_key in seen:
return None
seen.add(node_key)
if node_key in showtext_snapshots:
return showtext_snapshots[node_key]
ref_node = prompt_graph.get(node_key)
if not isinstance(ref_node, dict):
return None
inputs = ref_node.get('inputs', {})
if not isinstance(inputs, dict):
return None
bounded_inputs = dict(islice(inputs.items(), WORKFLOW_MAX_PORTS))
node_type_l = _node_type(ref_node).lower()
candidate_names = list(preferred_names)
candidate_names.extend(
['width', 'height', 'steps', 'cfg', 'seed', 'value', 'number', 'int', 'float']
if want == 'number'
else ['text', 'prompt', 'positive', 'negative', 'string', 'string_a', 'value', 'name']
)
selected = False
for name in candidate_names:
if name in bounded_inputs:
current = bounded_inputs[name]
selected = True
break
if not selected:
current = next(iter(bounded_inputs.values()), None)
if current in (None, ''):
return None
return None
def _extract_api_node_value(
prompt_graph, node, preferred_names=None, want='text', visited=None,
showtext_snapshots=None,
):
preferred_names = preferred_names or []
visited = visited or set()
inputs = node.get('inputs', {}) if isinstance(node, dict) else {}
if not isinstance(inputs, dict):
return None
node_type_l = _node_type(node).lower()
if want == 'text' and 'stringconcatenate' in node_type_l:
delimiter = inputs.get('delimiter', '')
if not isinstance(delimiter, str):
delimiter = ''
parts = []
for key in sorted([k for k in inputs if str(k).startswith('string_')]):
part = _resolve_api_value(
prompt_graph, inputs.get(key), want='text', visited=set(visited),
showtext_snapshots=showtext_snapshots,
)
if isinstance(part, str) and part.strip():
parts.append(part.strip())
if parts:
return _bounded_join_text(parts, delimiter)
for name in preferred_names:
if name in inputs:
resolved = _resolve_api_value(
prompt_graph, inputs.get(name), preferred_names, want, set(visited),
showtext_snapshots,
)
if resolved not in (None, ''):
return resolved
if want == 'text' and any(name in inputs for name in preferred_names):
# A preferred text/prompt field exists but is empty. Do not fall through into
# unrelated inputs such as clip/model/vae links and mistake filenames for prompts.
return None
if want == 'number':
for name in ['width', 'height', 'steps', 'cfg', 'seed', 'value', 'number', 'int', 'float']:
if name in inputs:
resolved = _resolve_api_value(
prompt_graph, inputs.get(name), preferred_names, want, set(visited),
showtext_snapshots,
)
if resolved is not None:
return resolved
for value in inputs.values():
resolved = _resolve_api_value(
prompt_graph, value, preferred_names, want, set(visited), showtext_snapshots,
)
if resolved is not None:
return resolved
return None
for name in ['text', 'prompt', 'positive', 'negative', 'string', 'string_a', 'value', 'name']:
if name in inputs:
resolved = _resolve_api_value(
prompt_graph, inputs.get(name), preferred_names, want, set(visited),
showtext_snapshots,
)
if isinstance(resolved, str) and resolved.strip():
return resolved.strip()
for value in inputs.values():
resolved = _resolve_api_value(
prompt_graph, value, preferred_names, want, set(visited), showtext_snapshots,
)
if isinstance(resolved, str) and resolved.strip():
return resolved.strip()
return None
def _add_lora(parsed, name, strength_model=1.0, strength_clip=None):
if len(parsed['loras']) >= WORKFLOW_MAX_LORAS:
return
if not name:
return
lora_name = _workflow_text(name).strip()
if lora_name in ['None', '', 'ComfyUI']:
return
new_identity = _normalized_lora_identity(lora_name)
new_has_path = '/' in new_identity
if strength_clip is None:
strength_clip = strength_model
entry = {
'name': lora_name,
'strength_model': strength_model,
'strength_clip': strength_clip,
}
for index, existing in enumerate(parsed['loras']):
existing_identity = _normalized_lora_identity(existing['name'])
if existing_identity == new_identity:
return
if _normalized_lora_basename(existing_identity) != _normalized_lora_basename(new_identity):
continue
existing_has_path = '/' in existing_identity
if not existing_has_path and new_has_path:
parsed['loras'][index] = entry
return
if existing_has_path and not new_has_path:
return
if not existing_has_path and not new_has_path:
return
parsed['loras'].append(entry)
def _structured_lora_entries(values):
for value in values:
if isinstance(value, dict) and isinstance(value.get('__value__'), list):
value = value['__value__']
if isinstance(value, list) and value and all(isinstance(item, dict) and 'name' in item for item in value):
return value[:WORKFLOW_MAX_PARAMS]
return None
def _active_api_lora_node_ids(prompt_graph):
"""Return LoRA nodes on the model path actually selected by samplers/guiders."""
model_roots = []
for node in prompt_graph.values():
if not isinstance(node, dict):
continue
node_type_l = _node_type(node).lower()
inputs = node.get('inputs', {})
if not isinstance(inputs, dict):
continue
is_sampler = 'sampler' in node_type_l and 'select' not in node_type_l
if is_sampler:
for input_name in ('model', 'guider'):
if _is_link_ref(inputs.get(input_name)):
model_roots.append(inputs[input_name])
if not model_roots:
return None
active_loras = set()
visited = set()
stack = list(model_roots)
while stack and len(visited) < WORKFLOW_MAX_NODES:
value = stack.pop()
if not _is_link_ref(value):
continue
node_id = str(value[0])
if node_id in visited:
continue
visited.add(node_id)
node = prompt_graph.get(node_id)
if not isinstance(node, dict):
continue
node_type_l = _node_type(node).lower()
inputs = node.get('inputs', {})
if not isinstance(inputs, dict):
continue
if 'lora' in node_type_l:
active_loras.add(node_id)
if 'switch' in node_type_l:
selector = next((inputs.get(name) for name in ('switch', 'select', 'index', 'choice') if name in inputs), None)
switch_value = _resolve_api_scalar(prompt_graph, selector)
normalized_switch = switch_value
if isinstance(normalized_switch, str):
text_value = normalized_switch.strip().lower()
if text_value in {'true', 'yes', 'on'}:
normalized_switch = True
elif text_value in {'false', 'no', 'off'}:
normalized_switch = False
else:
try:
normalized_switch = int(text_value)
except ValueError:
pass
branch_names = (
('on_true', 'true', 'if_true') if bool(normalized_switch)
else ('on_false', 'false', 'if_false')
)
selected = next((inputs[name] for name in branch_names if name in inputs), None)
if selected is None:
if isinstance(normalized_switch, bool):
branch_index = 1 if normalized_switch else 2
elif isinstance(normalized_switch, (int, float)):
branch_index = int(normalized_switch)
else:
branch_index = None
if branch_index is not None:
numbered_names = (
f'model{branch_index}', f'model_{branch_index}',
f'unet{branch_index}', f'unet_{branch_index}',
f'input{branch_index}', f'input_{branch_index}',
)
selected = next((inputs[name] for name in numbered_names if name in inputs), None)
if _is_link_ref(selected):
stack.append(selected)
continue
for name, linked_value in inputs.items():
name_l = str(name).lower()
follows_model = 'model' in name_l or 'unet' in name_l
follows_applied_stack = 'lora' in node_type_l and 'lora' in name_l
if _is_link_ref(linked_value) and (follows_model or follows_applied_stack):
stack.append(linked_value)
return active_loras
def parse_comfy_metadata(metadata):
"""Parse ComfyUI/SD metadata with broad heuristics"""
parsed = {
'prompt': None,
'negative_prompt': None,
'seed': None,
'steps': None,
'cfg': None,
'sampler': None,
'scheduler': None,
'model': None,
'loras': [],
'width': None,
'height': None,
'extras': {}
}
try:
# Try any known text-like fields first
negative_prompt_keys = {'Negative prompt', 'negative_prompt', 'negativePrompt'}
for key in [
'parameters',
'Comment',
'Description',
'UserComment',
'ImageDescription',
'Software',
'Prompt',
'prompt',
'Negative prompt',
'negative_prompt',
'negativePrompt',
'notes',
]:
if key in metadata and isinstance(metadata[key], str):
if key in negative_prompt_keys and not parsed['negative_prompt']:
parsed['negative_prompt'] = metadata[key].strip()
continue
_parse_parameters_text(metadata[key], parsed)
# If there is EXIF, scan for prompt-like text
exif = metadata.get('EXIF')
if isinstance(exif, dict):
for k, v in islice(exif.items(), 256):
if isinstance(v, str) and any(x in v for x in ['Steps:', 'Sampler:', 'Negative prompt:', 'CFG']):
_parse_parameters_text(v, parsed)
# First check if there's a 'parameters' field (Automatic1111/Forge/WebUI format)
if 'parameters' in metadata:
params_text = metadata['parameters']
if isinstance(params_text, str):
params_text = params_text[:WORKFLOW_MAX_PROMPT_LENGTH]
lines = params_text.split('\n')
full_text = '\n'.join(lines[1:]) if len(lines) > 1 else ''
# Also extract from Lora hashes field
lora_match = re.search(r'Lora hashes:\s*"([^"\n]+)', full_text)
if lora_match:
lora_text = lora_match.group(1)
for pair in islice(lora_text.split(','), WORKFLOW_MAX_LORAS):
if ':' in pair:
lora_name = pair.split(':')[0].strip()
strength_match = re.search(rf'<lora:{re.escape(lora_name)}:([\d.]+)>', parsed['prompt'] or '')
strength = float(strength_match.group(1)) if strength_match else 1.0
_add_lora(parsed, lora_name, strength, strength)
# ComfyUI UI workflow format. Newer ComfyUI can keep important nodes inside
# workflow.definitions.subgraphs, so iterate both top-level and subgraph nodes.
workflow = _decode_json_maybe(metadata.get('workflow', {}))
if isinstance(workflow, dict):
for node, _id_prefix, _subgraph_name in _iter_ui_workflow_nodes(workflow):
node_type = _node_type(node)
node_type_l = node_type.lower()
raw_widgets = node.get('widgets_values', [])
widgets = raw_widgets[:WORKFLOW_MAX_PARAMS] if isinstance(raw_widgets, list) else []
inputs = node.get('inputs', {})
title = _node_title(node).lower()
# Extract prompts from core and custom text-encode/prompt nodes.
if any(kw in node_type_l for kw in ['cliptextencode', 'textencode', 'prompttext']):
text = widgets[0] if widgets and isinstance(widgets[0], str) else None
if not text and isinstance(inputs, dict):
text = inputs.get('text') or inputs.get('prompt')
if text:
is_negative = any(word in title for word in ['negative', 'neg'])
if is_negative and not parsed['negative_prompt']:
parsed['negative_prompt'] = text
elif not is_negative and not parsed['prompt']:
parsed['prompt'] = text
# KSamplerSelect is only a sampler-name picker; treating its first
# widget as a seed corrupts the details panel (for example seed="lcm").
if 'ksampler' in node_type_l and 'select' not in node_type_l:
if not parsed['seed']:
parsed['seed'] = widgets[0] if widgets else (inputs.get('seed') if isinstance(inputs, dict) else None)
if not parsed['steps']:
parsed['steps'] = widgets[1] if len(widgets) > 1 else (inputs.get('steps') if isinstance(inputs, dict) else None)
if not parsed['cfg']:
parsed['cfg'] = widgets[2] if len(widgets) > 2 else (inputs.get('cfg') if isinstance(inputs, dict) else None)
if not parsed['sampler']:
parsed['sampler'] = widgets[3] if len(widgets) > 3 else (inputs.get('sampler_name') if isinstance(inputs, dict) else None)
if not parsed['scheduler']:
parsed['scheduler'] = widgets[4] if len(widgets) > 4 else (inputs.get('scheduler') if isinstance(inputs, dict) else None)
# Extract model/checkpoint names.
if any(kw in node_type_l for kw in ['checkpointloader', 'unetloader', 'modelloader']):
if not parsed['model']:
parsed['model'] = widgets[0] if widgets else None
if not parsed['model'] and isinstance(inputs, dict):
for key in ['ckpt_name', 'unet_name', 'model_name', 'name']:
if inputs.get(key):
parsed['model'] = inputs.get(key)
break
# Extract LoRAs from LoRA Manager, standard loaders, stackers, and custom loaders.
if any(kw in node_type_l for kw in ['lora', 'loraloader', 'lora stacker', 'lora_stack']):
if node.get('mode', 0) != 0:
continue
try:
# LoraManager stores a human-readable string containing every
# inventory entry in widget 0 and the authoritative structured
# list (including each entry's active flag) in a later widget.
# Never treat that display string as one giant LoRA name.
structured_loras = next(
(
value[:WORKFLOW_MAX_PARAMS] for value in widgets
if isinstance(value, list)
and value
and all(
isinstance(item, dict) and 'name' in item
for item in islice(value, WORKFLOW_MAX_PARAMS)
)
),
None,
)
if structured_loras is None:
if widgets and isinstance(widgets[0], list) and not widgets[0]:
# Preserve the original empty-list convention used by
# stackers that put their structured data in widget 0.
structured_loras = widgets[0]
elif 'loramanager' in node_type_l:
# An empty later list is authoritative only for
# LoraManager. Other loaders can have unrelated empty
# list widgets after their normal name/strength fields.
structured_loras = next(
(value for value in widgets if isinstance(value, list) and not value),
None,
)
if structured_loras is not None:
for lora_obj in structured_loras:
if lora_obj.get('active', True):
_add_lora(
parsed,
lora_obj.get('name'),
lora_obj.get('strength', 1.0),
lora_obj.get('clipStrength', lora_obj.get('strength', 1.0)),
)
else:
lora_name = widgets[0] if widgets else None
strength_model = widgets[1] if len(widgets) > 1 else 1.0
strength_clip = widgets[2] if len(widgets) > 2 else strength_model
if isinstance(inputs, dict):
lora_name = lora_name or inputs.get('lora_name') or inputs.get('name')
strength_model = inputs.get('strength_model', inputs.get('lora_strength', inputs.get('strength', strength_model)))
strength_clip = inputs.get('strength_clip', inputs.get('clip_strength', inputs.get('clipStrength', strength_model)))
_add_lora(parsed, lora_name, strength_model, strength_clip)
except Exception as e:
print(f"Error parsing LoRAs from node {node_type}: {e}")
# Extract dimensions.
if 'emptylatentimage' in node_type_l or ('latent' in node_type_l and isinstance(inputs, dict) and 'width' in inputs and 'height' in inputs):
if not parsed['width']:
parsed['width'] = widgets[0] if widgets else inputs.get('width')
if not parsed['height']:
parsed['height'] = widgets[1] if len(widgets) > 1 else inputs.get('height')
# Parse API prompt graph JSON (ComfyUI default Save Image metadata). This is the
# most reliable source for images saved by core SaveImage and most custom save nodes.
prompt_graph = _decode_json_maybe(metadata.get('prompt'))
if isinstance(prompt_graph, dict):
bounded_prompt_graph = {}
for node_id, node in islice(prompt_graph.items(), WORKFLOW_MAX_NODES):
if not isinstance(node, dict):
bounded_prompt_graph[node_id] = node
continue
bounded_node = {
key: node[key]
for key in ('class_type', 'type', 'title')
if key in node
}
node_meta = node.get('_meta')
if isinstance(node_meta, dict) and 'title' in node_meta:
bounded_node['_meta'] = {'title': _workflow_text(node_meta.get('title'))}
node_inputs = node.get('inputs')
if isinstance(node_inputs, dict):
bounded_node['inputs'] = dict(islice(node_inputs.items(), WORKFLOW_MAX_PORTS))
bounded_prompt_graph[node_id] = bounded_node
prompt_graph = bounded_prompt_graph
# The API prompt is the execution graph. Replace speculative LoRAs
# collected from the UI workflow snapshot with its selected model path.
parsed['loras'] = []
active_api_loras = _active_api_lora_node_ids(prompt_graph)
showtext_snapshots = _build_showtext_snapshots(prompt_graph)
text_candidates = []
api_negative_input_seen = False
for node_id, node in prompt_graph.items():
if not isinstance(node, dict):
continue
node_type = _node_type(node)
node_type_l = node_type.lower()
title_l = _node_title(node).lower()
node_inputs = node.get('inputs', {})
if not isinstance(node_inputs, dict):
continue
is_sampling_node = (
('ksampler' in node_type_l and 'select' not in node_type_l)
or 'samplercustom' in node_type_l
)
if is_sampling_node:
# Prefer the API prompt graph over the UI workflow for sampler settings;
# UI widgets include control widgets (for example randomize/fixed) that
# shifted position in recent ComfyUI versions.
seed_input = node_inputs.get('seed', node_inputs.get('noise_seed'))
if seed_input is None:
seed_input = node_inputs.get('noise')
seed_value = _resolve_api_value(
prompt_graph, seed_input,
['noise_seed', 'seed', 'value'], 'number',
showtext_snapshots=showtext_snapshots,
)
if seed_value is not None:
parsed['seed'] = seed_value
if node_inputs.get('steps') is not None:
parsed['steps'] = node_inputs.get('steps')
if node_inputs.get('cfg') is not None:
parsed['cfg'] = node_inputs.get('cfg')
if node_inputs.get('sampler_name') is not None:
parsed['sampler'] = node_inputs.get('sampler_name')
if node_inputs.get('scheduler') is not None:
parsed['scheduler'] = node_inputs.get('scheduler')
# SamplerCustomAdvanced stores settings in linked helper nodes.
if 'samplercustomadvanced' in node_type_l:
cfg_value = _resolve_api_value(
prompt_graph, node_inputs.get('guider'), ['cfg'], 'number',
showtext_snapshots=showtext_snapshots,
)
if cfg_value is not None:
parsed['cfg'] = cfg_value
sampler_value = _resolve_api_value(
prompt_graph, node_inputs.get('sampler'), ['sampler_name'], 'text',
showtext_snapshots=showtext_snapshots,
)
if sampler_value:
parsed['sampler'] = sampler_value
sigma_text = _resolve_api_value(
prompt_graph, node_inputs.get('sigmas'), ['sigmas'], 'text',
showtext_snapshots=showtext_snapshots,
)
if isinstance(sigma_text, str):
sigma_values = [value.strip() for value in sigma_text.split(',') if value.strip()]
if len(sigma_values) >= 2:
parsed['steps'] = len(sigma_values) - 1
if not parsed['scheduler']:
parsed['scheduler'] = 'manual sigmas'
# Guider/conditioning nodes encode the semantic role of otherwise
# identically titled CLIP text nodes. Follow those links before using
# title/order heuristics.
has_prompt_roles = is_sampling_node or (
'positive' in node_inputs and 'negative' in node_inputs
)
if has_prompt_roles:
positive_text = _resolve_api_value(
prompt_graph, node_inputs.get('positive'),
['text', 'prompt', 'positive', 'string_a', 'string'], 'text',
showtext_snapshots=showtext_snapshots,
)
if positive_text:
parsed['prompt'] = positive_text
if 'negative' in node_inputs:
api_negative_input_seen = True
negative_text = _resolve_api_value(
prompt_graph, node_inputs.get('negative'),
['text', 'prompt', 'negative', 'string_a', 'string'], 'text',
showtext_snapshots=showtext_snapshots,
)
if negative_text:
parsed['negative_prompt'] = negative_text
elif 'negative' in node_inputs:
# The API graph explicitly says the negative input is empty. Do not keep
# stale/wrong text guessed from UI widgets (recent ComfyUI widgets moved).
parsed['negative_prompt'] = None
# Newer/custom graphs can split sampling into SamplerCustom,
# KSamplerSelect, and BasicScheduler nodes.
if 'ksamplerselect' in node_type_l and node_inputs.get('sampler_name') is not None:
parsed['sampler'] = node_inputs.get('sampler_name')
if 'scheduler' in node_type_l:
if node_inputs.get('steps') is not None:
parsed['steps'] = node_inputs.get('steps')
if node_inputs.get('scheduler') is not None:
parsed['scheduler'] = node_inputs.get('scheduler')
if any(key in node_inputs for key in ['ckpt_name', 'unet_name', 'model_name']) or any(kw in node_type_l for kw in ['checkpointloader', 'unetloader', 'modelloader', 'ditloader']):
# Checkpoint/UNet names are the generation model. A generic
# model_name on a captioner or VLM helper must not overwrite it.
primary_model = node_inputs.get('ckpt_name') or node_inputs.get('unet_name')
if isinstance(primary_model, str) and primary_model.strip():
parsed['model'] = primary_model.strip()
elif not parsed['model']:
for key in ['model_name', 'name']:
if isinstance(node_inputs.get(key), str) and node_inputs.get(key).strip():
parsed['model'] = node_inputs.get(key).strip()
break
if any(kw in node_type_l for kw in ['lora', 'loraloader', 'lora_stack', 'lora stacker']):
if active_api_loras is not None and str(node_id) not in active_api_loras:
continue
structured_loras = _structured_lora_entries(node_inputs.values())
if structured_loras is not None:
for lora_obj in structured_loras:
if lora_obj.get('active', True):
_add_lora(
parsed,
lora_obj.get('name'),
lora_obj.get('strength', 1.0),
lora_obj.get('clipStrength', lora_obj.get('strength', 1.0)),
)
else:
lora_name = _resolve_api_value(
prompt_graph, node_inputs.get('lora_name') or node_inputs.get('name'),
['lora_name', 'name'], 'text', showtext_snapshots=showtext_snapshots,
)
strength_model = node_inputs.get('strength_model', node_inputs.get('lora_strength', node_inputs.get('strength', 1.0)))
strength_clip = node_inputs.get('strength_clip', node_inputs.get('clip_strength', node_inputs.get('clipStrength', strength_model)))
_add_lora(parsed, lora_name, strength_model, strength_clip)
if 'emptylatentimage' in node_type_l or ('latent' in node_type_l and 'width' in node_inputs and 'height' in node_inputs):
width_value = _resolve_api_value(
prompt_graph, node_inputs.get('width'), ['width'], 'number',
showtext_snapshots=showtext_snapshots,
)
height_value = _resolve_api_value(
prompt_graph, node_inputs.get('height'), ['height'], 'number',
showtext_snapshots=showtext_snapshots,
)
if width_value is not None:
parsed['width'] = width_value
if height_value is not None:
parsed['height'] = height_value
if (
any(kw in node_type_l for kw in ['cliptextencode', 'textencode', 'conditioning', 'prompt'])
or any(key in node_inputs for key in ['text', 'prompt', 'positive', 'negative'])
):
text = _extract_api_node_value(
prompt_graph, node, ['text', 'prompt', 'positive', 'string_a', 'string'], 'text',
showtext_snapshots=showtext_snapshots,
)
if isinstance(text, str) and text.strip():
text_candidates.append((text.strip(), title_l))
if not parsed['negative_prompt'] and not api_negative_input_seen:
for text, title in text_candidates:
if any(word in title for word in ['negative', 'neg']):
parsed['negative_prompt'] = text
break
if not parsed['prompt']:
for text, title in text_candidates:
if not any(word in title for word in ['negative', 'neg']):
parsed['prompt'] = text
break
# Do not invent a negative prompt from arbitrary extra text nodes. If a
# workflow has a real negative prompt, it is either linked from the sampler
# negative input or explicitly titled negative/neg above.
if text_candidates and not parsed['prompt']:
for text, _ in text_candidates:
parsed['prompt'] = text
break
except Exception as e:
print(f"Error parsing metadata: {e}")
import traceback
traceback.print_exc()
# Final normalization: prompt display should not duplicate LoRA entries.
parsed['prompt'] = _sanitize_prompt_text(parsed.get('prompt'))
parsed['negative_prompt'] = _sanitize_prompt_text(parsed.get('negative_prompt'))
if parsed.get('prompt') and parsed.get('negative_prompt') == parsed.get('prompt'):
# Some workflows/models have no real negative prompt but the default ComfyUI
# SaveImage API graph still routes the same conditioning/text to positive and
# negative. Showing the same block twice is misleading; treat exact duplicates
# as an absent negative prompt.
parsed['negative_prompt'] = None
return _finite_json_value(parsed)
def flatten_metadata(metadata):
flat = {}
for key, value in islice(metadata.items(), 256):
if key in {'workflow', 'prompt'}:
continue
if isinstance(value, dict):
for k2, v2 in islice(value.items(), 256 - len(flat)):
flat[f"{_workflow_text(key)}.{_workflow_text(k2)}"] = _workflow_text(v2)
else:
flat[_workflow_text(key)] = _workflow_text(value)
if len(flat) >= 256:
break
return flat
def _normalize_node_params_from_ui_node(node, limit=WORKFLOW_MAX_PARAMS, value_formatter=None):
params = []
def add_param(name, value):
if len(params) >= limit:
return False
formatted_value = value_formatter(name, value) if value_formatter else _workflow_text(value)
params.append({'name': _workflow_text(name), 'value': formatted_value})
return True
widgets = node.get('widgets_values', [])
widgets = widgets if isinstance(widgets, list) else []
widget_cursor = 0
inputs = node.get('inputs', {})
if isinstance(inputs, list):
for idx, item in enumerate(islice(inputs, limit)):
if isinstance(item, dict):
name = item.get('name') or f"input_{idx + 1}"
has_widget = item.get('widget') is not None
if item.get('link') is not None:
value = item.get('link')
elif 'value' in item:
value = item.get('value')
elif has_widget and widget_cursor < len(widgets):
value = widgets[widget_cursor]
elif has_widget:
value = item.get('widget')
else:
continue
if not add_param(name, value):
break
if has_widget:
widget_cursor += 1
elif isinstance(inputs, dict):
for name, value in islice(inputs.items(), limit):
if not add_param(name, value):
break
# Preserve extra custom-node widgets without duplicating the named values above.
remaining = max(0, limit - len(params))
for idx, value in enumerate(islice(widgets[widget_cursor:], remaining), start=widget_cursor):
if not add_param(f"widget_{idx + 1}", value):
break
return params
def extract_workflow_nodes(metadata):
"""Extract workflow nodes for sidebar node inspector (UI and API workflow formats)."""
workflow_data = metadata.get('workflow')
prompt_data = metadata.get('prompt')
source = workflow_data if workflow_data is not None else prompt_data
if isinstance(source, str):
try:
source = json.loads(source)
except Exception:
return []
nodes_out = []
# UI format: {"nodes":[...], "definitions":{"subgraphs":[...]}}
if isinstance(source, dict) and isinstance(source.get('nodes'), list):
for node, id_prefix, subgraph_name in _iter_ui_workflow_nodes(source):
if len(nodes_out) >= WORKFLOW_MAX_NODES:
break
if not isinstance(node, dict):
continue
node_id = node.get('id', 'N/A')
node_type = node.get('type') or node.get('class_type') or 'Unknown'
node_meta = node.get('_meta') if isinstance(node.get('_meta'), dict) else {}
node_title = node.get('title') or node_meta.get('title')
params = _normalize_node_params_from_ui_node(node)
if subgraph_name:
params.insert(0, {'name': 'subgraph', 'value': subgraph_name})
params = params[:WORKFLOW_MAX_PARAMS]
nodes_out.append({
'id': _workflow_id(f"{id_prefix}{node_id}"),
'type': _workflow_text(node_type),
'title': _workflow_text(node_title or node_type),
'mode': node.get('mode', 0),
'params': params
})
# API format: {"3":{"class_type":"KSampler","inputs":{...}}, ...}
elif isinstance(source, dict):
for node_id, node in islice(source.items(), WORKFLOW_MAX_NODES):
if not isinstance(node, dict) or 'class_type' not in node:
continue
params = []
inputs = node.get('inputs', {})
if isinstance(inputs, dict):
for name, value in islice(inputs.items(), WORKFLOW_MAX_PARAMS):
params.append({'name': _workflow_text(name), 'value': _workflow_text(value)})
nodes_out.append({
'id': _workflow_id(node_id),
'type': _workflow_text(node.get('class_type', 'Unknown')),
'title': _workflow_text(_node_title(node) or node.get('class_type', 'Unknown')),
'mode': 0,
'params': params
})
def _sort_key(item):
node_id = item.get('id', '')
try:
return (0, int(node_id))
except Exception:
return (1, str(node_id))
nodes_out.sort(key=_sort_key)
return nodes_out
def build_workflow_graph(workflow_data, workflow_type):
"""Normalize embedded workflow data for the read-only visual graph viewer."""
if not isinstance(workflow_data, dict):
return {"kind": workflow_type or None, "nodes": [], "links": [], "groups": []}
max_slot = WORKFLOW_MAX_PORTS - 1
max_nodes = WORKFLOW_MAX_NODES
max_links = WORKFLOW_MAX_LINKS
max_subgraphs = WORKFLOW_MAX_SUBGRAPHS
max_params = WORKFLOW_MAX_PARAMS
graph_text = _workflow_text
graph_id = _workflow_id
remaining_param_text = [WORKFLOW_MAX_EMBEDDED_JSON_BYTES]
remaining_ports = [WORKFLOW_MAX_TOTAL_PORTS]
def graph_param_text(name, value, node_type=""):
if not isinstance(value, str):
return graph_text(value)
available = max(0, min(WORKFLOW_MAX_PROMPT_LENGTH, remaining_param_text[0]))
text = value[:available]
remaining_param_text[0] -= len(text)
return text
def graph_param(name, value, node_type=""):
text = graph_param_text(name, value, node_type)
hint = str(name).lower()
multiline = isinstance(text, str) and (
len(text) > 80 or "\n" in text or any(word in hint for word in ("text", "prompt", "caption"))
)
return {"name": graph_text(name), "value": text, "multiline": multiline}
def finite_number(value, fallback, minimum=-1_000_000, maximum=1_000_000):
try:
number = float(value)
except (TypeError, ValueError):
return float(fallback)
if not math.isfinite(number) or number < minimum or number > maximum:
return float(fallback)
return number
def pair(value, fallback, minimum=-1_000_000, maximum=1_000_000):
if isinstance(value, (list, tuple)) and len(value) >= 2:
return [
finite_number(value[0], fallback[0], minimum, maximum),
finite_number(value[1], fallback[1], minimum, maximum),
]
return [float(fallback[0]), float(fallback[1])]
def bounded_slot(value):
if not isinstance(value, int) or isinstance(value, bool):
return None
return value if 0 <= value <= max_slot else None
def slots(value):
if not isinstance(value, list):
return []
result = []
available = min(max_slot + 1, remaining_ports[0])
for index, item in enumerate(value[:available]):
if not isinstance(item, dict):
result.append({"name": f"slot_{index + 1}", "type": ""})
continue
result.append({
"name": graph_text(item.get("label") or item.get("localized_name") or item.get("name") or f"slot_{index + 1}"),
"type": graph_text(item.get("type") or ""),
})
remaining_ports[0] -= len(result)
return result
def generated_slots(records):
available = min(len(records), remaining_ports[0])
result = records[:available]
remaining_ports[0] -= len(result)
return result
def reserve_identifier(candidate, used, next_suffixes):
candidate = graph_id(candidate)
if candidate not in used:
used.add(candidate)
next_suffixes.setdefault(candidate, 2)
return candidate
suffix = next_suffixes.get(candidate, 2)
while True:
marker = f"~{suffix}"
alternate = f"{candidate[:max(0, WORKFLOW_MAX_ID_LENGTH - len(marker))]}{marker}"
suffix += 1
if alternate not in used:
used.add(alternate)
next_suffixes[candidate] = suffix
return alternate
if workflow_type == "api":
api_nodes = {}
api_aliases = {}
used_api_ids = set()
api_id_suffixes = {}
def api_key(value):
return (type(value).__name__, graph_id(value))
for raw_node_id, node in islice(workflow_data.items(), max_nodes):
if isinstance(node, dict) and node.get("class_type"):
candidate = graph_id(raw_node_id)
node_id = reserve_identifier(candidate, used_api_ids, api_id_suffixes)
api_aliases[api_key(raw_node_id)] = node_id
api_aliases.setdefault(("*", candidate), node_id)
api_nodes[node_id] = node
def node_sort_key(node_id):
try:
return (0, int(node_id))
except (TypeError, ValueError):
return (1, node_id)
ordered_ids = sorted(api_nodes, key=node_sort_key)
incoming = {node_id: set() for node_id in ordered_ids}
outgoing = {node_id: set() for node_id in ordered_ids}
linked_inputs = {node_id: set() for node_id in ordered_ids}
api_links = []
max_output_slot = {}
for target_id in ordered_ids:
inputs = api_nodes[target_id].get("inputs", {})
if not isinstance(inputs, dict):
continue
for input_index, (input_name, value) in enumerate(islice(inputs.items(), max_slot + 1)):
if not _is_link_ref(value):
continue
source_id = api_aliases.get(api_key(value[0])) or api_aliases.get(("*", graph_id(value[0])))
if source_id not in api_nodes:
continue
source_slot = bounded_slot(value[1] if len(value) > 1 else 0)
if source_slot is None:
continue
incoming[target_id].add(source_id)
outgoing[source_id].add(target_id)
linked_inputs[target_id].add(input_name)
max_output_slot[source_id] = max(max_output_slot.get(source_id, -1), source_slot)
api_links.append({
"id": f"api-{len(api_links) + 1}",
"from_node": source_id,
"from_slot": source_slot,
"to_node": target_id,
"to_slot": input_index,
"type": "",
})
if len(api_links) >= max_links:
break
if len(api_links) >= max_links:
break
indegree = {node_id: len(incoming[node_id]) for node_id in ordered_ids}
depths = {node_id: 0 for node_id in ordered_ids}
queue = sorted((node_id for node_id in ordered_ids if indegree[node_id] == 0), key=node_sort_key)
processed = set()
cursor = 0
while cursor < len(queue):
node_id = queue[cursor]
cursor += 1
processed.add(node_id)
for target_id in sorted(outgoing[node_id], key=node_sort_key):
depths[target_id] = max(depths[target_id], depths[node_id] + 1)
indegree[target_id] -= 1
if indegree[target_id] == 0:
queue.append(target_id)
if len(processed) != len(ordered_ids):
cycle_depth = max((depths[node_id] for node_id in processed), default=-1) + 1
for node_id in ordered_ids:
if node_id not in processed:
depths[node_id] = cycle_depth
next_y_by_depth = {}
api_graph_nodes = []
for node_id in ordered_ids:
node = api_nodes[node_id]
depth = depths[node_id]
inputs = node.get("inputs", {}) if isinstance(node.get("inputs"), dict) else {}
input_items = list(islice(inputs.items(), max_slot + 1))
node_type = graph_text(node.get("class_type") or "Unknown")
params = [
graph_param(name, value, node_type)
for name, value in input_items[:max_params]
if name not in linked_inputs[node_id]
]
input_slots = generated_slots([
{"name": graph_text(name), "type": ""}
for name, _value in input_items
])
output_slots = generated_slots([
{"name": f"output_{slot}", "type": ""}
for slot in range(max_output_slot.get(node_id, -1) + 1)
])
parameter_height = 14 + sum(104 if param.get("multiline") else 22 for param in params) if params else 0
node_height = float(max(120, 48 + max(len(input_slots), len(output_slots)) * 22 + parameter_height))
node_y = next_y_by_depth.get(depth, 0.0)
next_y_by_depth[depth] = node_y + node_height + 90.0
meta = node.get("_meta") if isinstance(node.get("_meta"), dict) else {}
api_graph_nodes.append({
"id": node_id,
"type": node_type,
"title": graph_text(meta.get("title") or node_type),
"position": [float(depth * 340), node_y],
"size": [260.0, node_height],
"inputs": input_slots,
"outputs": output_slots,
"params": params,
"mode": 0,
"color": "",
"bgcolor": "",
})
api_nodes_by_id = {node["id"]: node for node in api_graph_nodes}
api_links = [
link for link in api_links
if link["from_node"] in api_nodes_by_id
and link["to_node"] in api_nodes_by_id
and link["from_slot"] < len(api_nodes_by_id[link["from_node"]]["outputs"])
and link["to_slot"] < len(api_nodes_by_id[link["to_node"]]["inputs"])
]
return {"kind": "api", "nodes": api_graph_nodes, "links": api_links, "groups": []}
if workflow_type != "ui" or not isinstance(workflow_data.get("nodes"), list):
return {"kind": workflow_type or None, "nodes": [], "links": [], "groups": []}
used_node_ids = set()
used_link_ids = set()
node_id_suffixes = {}
link_id_suffixes = {}
definitions = workflow_data.get("definitions")
raw_subgraphs = definitions.get("subgraphs") if isinstance(definitions, dict) else []
if not isinstance(raw_subgraphs, list):
raw_subgraphs = []
subgraph_definitions = {
graph_id(item.get("id")): item
for item in raw_subgraphs[:max_subgraphs]
if isinstance(item, dict) and item.get("id") is not None
}
def normalize_ui_node(node, index, aliases, id_prefix="", subgraph_name=None):
node_type = graph_text(node.get("type") or node.get("class_type") or "Unknown")
definition = subgraph_definitions.get(graph_id(node.get("type"))) if not id_prefix else None
params = []
for param in _normalize_node_params_from_ui_node(
node,
max_params,
lambda name, value: graph_param_text(name, value, node_type),
):
if isinstance(param, dict):
value = param.get("value", "")
hint = str(param.get('name', '')).lower()
params.append({
"name": graph_text(param.get("name")),
"value": value,
"multiline": isinstance(value, str) and (
len(value) > 80 or "\n" in value or any(word in hint for word in ("text", "prompt", "caption"))
),
})
if definition:
widget_values = node.get("widgets_values")
widget_values = widget_values if isinstance(widget_values, list) else []
semantic_params = []
definition_inputs = definition.get("inputs") if isinstance(definition.get("inputs"), list) else []
# ComfyUI serializes subgraph widget values in the same order as the
# definition's trailing scalar inputs. Boundary media ports do not have
# widget values, so align from the end instead of guessing from value_* names.
widget_ports = definition_inputs[-len(widget_values):] if widget_values else []
for widget_index, (port, widget_value) in enumerate(zip(widget_ports, widget_values)):
if not isinstance(port, dict):
continue
name = port.get("label") or port.get("name") or f"widget_{widget_index + 1}"
semantic_params.append(graph_param(name, widget_value, node_type))
if semantic_params:
params = semantic_params[:max_params]
scoped_id = f"{id_prefix}{graph_id(node.get('id', index))}"
node_id = reserve_identifier(scoped_id, used_node_ids, node_id_suffixes)
aliases.setdefault(scoped_id, node_id)
normalized = {
"id": node_id,
"type": node_type,
"title": graph_text(node.get("title") or (definition or {}).get("name") or node.get("type") or node.get("class_type") or "Unknown"),
"position": pair(node.get("pos"), (index % 4 * 320, index // 4 * 250)),
"size": pair(node.get("size"), (240, 150), 1, 10_000),
"inputs": slots((definition or {}).get("inputs") or node.get("inputs")),
"outputs": slots((definition or {}).get("outputs") or node.get("outputs")),
"params": params,
"mode": finite_number(node.get("mode", 0), 0),
"color": graph_text(node.get("color")),
"bgcolor": graph_text(node.get("bgcolor")),
}
if subgraph_name:
normalized["subgraph"] = subgraph_name
return normalized
def append_ui_links(destination, raw_links, scope_nodes, aliases, id_prefix=""):
if not isinstance(raw_links, (list, tuple)):
return
nodes_by_id = {node["id"]: node for node in scope_nodes}
remaining_links = max(0, max_links - len(destination))
for index, link in enumerate(islice(raw_links, remaining_links)):
if isinstance(link, dict):
raw_id = link.get("id", index)
raw_from_node = link.get("origin_id", link.get("from_node"))
raw_from_slot = link.get("origin_slot", link.get("from_slot"))
raw_to_node = link.get("target_id", link.get("to_node"))
raw_to_slot = link.get("target_slot", link.get("to_slot"))
link_type = link.get("type", "")
elif isinstance(link, (list, tuple)) and len(link) >= 5:
raw_id = link[0] if link[0] is not None else index
raw_from_node = link[1]
raw_from_slot = link[2]
raw_to_node = link[3]
raw_to_slot = link[4]
link_type = link[5] if len(link) > 5 else ""
else:
continue
from_slot = bounded_slot(raw_from_slot)
to_slot = bounded_slot(raw_to_slot)
if from_slot is None or to_slot is None:
continue
from_node = aliases.get(f"{id_prefix}{graph_id(raw_from_node)}")
to_node = aliases.get(f"{id_prefix}{graph_id(raw_to_node)}")
source = nodes_by_id.get(from_node)
target = nodes_by_id.get(to_node)
if not source or not target:
continue
if from_slot >= len(source["outputs"]) or to_slot >= len(target["inputs"]):
continue
destination.append({
"id": reserve_identifier(f"{id_prefix}{graph_id(raw_id)}", used_link_ids, link_id_suffixes),
"from_node": from_node,
"from_slot": from_slot,
"to_node": to_node,
"to_slot": to_slot,
"type": graph_text(link_type),
})
top_aliases = {}
top_nodes = [
normalize_ui_node(node, index, top_aliases)
for index, node in enumerate((workflow_data.get("nodes") or [])[:max_nodes])
if isinstance(node, dict)
]
nodes = list(top_nodes)
links = []
groups = []
append_ui_links(links, workflow_data.get("links"), top_nodes, top_aliases)
top_right = max((node["position"][0] + node["size"][0] for node in top_nodes), default=-260.0)
top_y = min((node["position"][1] for node in top_nodes), default=0.0)
subgraph_x = top_right + 260.0
subgraph_y = top_y
subgraphs = raw_subgraphs
used_subgraph_ids = set()
subgraph_id_suffixes = {}
for subgraph_index, subgraph in enumerate(subgraphs[:max_subgraphs]):
if len(nodes) >= max_nodes:
break
if not isinstance(subgraph, dict) or not isinstance(subgraph.get("nodes"), list):
continue
base_subgraph_id = graph_id(subgraph.get("id") or subgraph.get("name") or f"subgraph-{subgraph_index + 1}")
subgraph_id = reserve_identifier(base_subgraph_id, used_subgraph_ids, subgraph_id_suffixes)
subgraph_name = graph_text(subgraph.get("name") or subgraph_id)
id_prefix = f"{subgraph_id}:"
subgraph_aliases = {}
remaining_nodes = max_nodes - len(nodes)
subgraph_nodes = [
normalize_ui_node(node, index, subgraph_aliases, id_prefix, subgraph_name)
for index, node in enumerate((subgraph.get("nodes") or [])[:remaining_nodes])
if isinstance(node, dict)
]
for interface_key, title, ports_key, ports_side in (
("inputNode", "Subgraph Inputs", "inputs", "outputs"),
("outputNode", "Subgraph Outputs", "outputs", "inputs"),
):
if len(nodes) + len(subgraph_nodes) >= max_nodes:
break
interface = subgraph.get(interface_key)
if not isinstance(interface, dict) or interface.get("id") is None:
continue
bounding = interface.get("bounding")
if not isinstance(bounding, (list, tuple)):
bounding = []
scoped_interface_id = f"{id_prefix}{graph_id(interface.get('id'))}"
interface_node_id = reserve_identifier(scoped_interface_id, used_node_ids, node_id_suffixes)
subgraph_aliases.setdefault(scoped_interface_id, interface_node_id)
interface_node = {
"id": interface_node_id,
"type": title.replace(" ", ""),
"title": title,
"position": pair(bounding[:2], (0, 0)),
"size": pair(bounding[2:4], (140, 120), 1, 10_000),
"inputs": slots(subgraph.get(ports_key)) if ports_side == "inputs" else [],
"outputs": slots(subgraph.get(ports_key)) if ports_side == "outputs" else [],
"params": [],
"mode": 0,
"color": "",
"bgcolor": "",
"subgraph": subgraph_name,
}
subgraph_nodes.append(interface_node)
if not subgraph_nodes:
continue
def rendered_node_size(node):
width = max(210.0, min(420.0, node["size"][0]))
content_height = 48.0 + max(len(node["inputs"]), len(node["outputs"])) * 22.0
if node["params"]:
content_height += 14.0 + sum(
104.0 if param.get("multiline") else 22.0
for param in node["params"][:7]
)
height = max(92.0, min(12_000.0, max(node["size"][1], content_height)))
return width, height
local_min_x = min(node["position"][0] for node in subgraph_nodes)
local_min_y = min(node["position"][1] for node in subgraph_nodes)
local_max_x = max(
node["position"][0] + rendered_node_size(node)[0]
for node in subgraph_nodes
)
local_max_y = max(
node["position"][1] + rendered_node_size(node)[1]
for node in subgraph_nodes
)
padding_x = 74.0
padding_top = 88.0
padding_bottom = 64.0
group_width = local_max_x - local_min_x + padding_x * 2
group_height = local_max_y - local_min_y + padding_top + padding_bottom
for node in subgraph_nodes:
node["position"] = [
subgraph_x + padding_x + node["position"][0] - local_min_x,
subgraph_y + padding_top + node["position"][1] - local_min_y,
]
groups.append({
"id": graph_id(f"subgraph:{subgraph_id}"),
"title": subgraph_name,
"position": [subgraph_x, subgraph_y],
"size": [group_width, group_height],
"subgraph": True,
})
nodes.extend(subgraph_nodes)
append_ui_links(links, subgraph.get("links"), subgraph_nodes, subgraph_aliases, id_prefix)
subgraph_y += group_height + 140.0
return {"kind": "ui", "nodes": nodes, "links": links, "groups": groups}