fix: support default SaveImage metadata parsing

- Parse ComfyUI API prompt metadata from default SaveImage nodes
- Resolve linked prompt, sampler, size, model, and LoRA fields
- Include workflow nodes stored inside subgraph definitions
- Suppress duplicate negative prompts when no real negative prompt exists
- Add regression coverage for default SaveImage metadata cases
This commit is contained in:
Flibens
2026-07-04 20:35:14 -06:00
parent c9cee90f8a
commit 7fa5a41fa3
3 changed files with 539 additions and 163 deletions
+1 -1
View File
@@ -36,7 +36,7 @@ Option 2 (git):
In ComfyUI, click the **Image Browser** button in the top menu bar.
## Notes:
If you have problems viewing metadata, use a different save image node, i would recommend Lora Manager´s Save Image node.
Metadata parsing supports ComfyUI core SaveImage, LoRA Manager Save Image, and other save nodes that preserve ComfyUI `prompt`/`workflow` metadata, including workflow nodes stored inside subgraphs.
Favorites and added folders are stored in ComfyUI user data (`user/comfyui-image-browser`) so they survive node updates.
+326 -162
View File
@@ -509,6 +509,169 @@ def _sanitize_prompt_text(prompt_text):
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):
return isinstance(value, (list, tuple)) and len(value) >= 1 and isinstance(value[0], (str, int))
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
for node in workflow_data.get('nodes', []) or []:
if isinstance(node, dict):
yield node, '', None
definitions = workflow_data.get('definitions')
if not isinstance(definitions, dict):
return
for subgraph in definitions.get('subgraphs', []) or []:
if not isinstance(subgraph, dict):
continue
subgraph_id = _safe_str(subgraph.get('id') or subgraph.get('name') or 'subgraph')
subgraph_name = _safe_str(subgraph.get('name') or subgraph_id)
for node in subgraph.get('nodes', []) or []:
if isinstance(node, dict):
yield node, f'{subgraph_id}:', subgraph_name
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 _resolve_api_value(prompt_graph, value, preferred_names=None, want='text', visited=None):
"""Resolve a ComfyUI API graph input, following links through text/prompt/string/size helper nodes."""
if visited is None:
visited = set()
preferred_names = preferred_names or []
if _is_link_ref(value):
node_key = str(value[0])
if node_key in visited:
return None
visited.add(node_key)
ref_node = prompt_graph.get(node_key)
if not isinstance(ref_node, dict):
return None
return _extract_api_node_value(prompt_graph, ref_node, preferred_names, want, visited)
if want == 'number':
return _coerce_number(value)
if isinstance(value, str):
return value.strip()
if value is not None and want != 'text':
return value
return None
def _extract_api_node_value(prompt_graph, node, preferred_names=None, want='text', visited=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))
if isinstance(part, str) and part.strip():
parts.append(part.strip())
if parts:
return delimiter.join(parts).strip()
for name in preferred_names:
if name in inputs:
resolved = _resolve_api_value(prompt_graph, inputs.get(name), preferred_names, want, set(visited))
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))
if resolved is not None:
return resolved
for value in inputs.values():
resolved = _resolve_api_value(prompt_graph, value, preferred_names, want, set(visited))
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))
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))
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 not name:
return
lora_name = str(name).strip()
if lora_name in ['None', '', 'ComfyUI']:
return
if any(l['name'] == lora_name for l in parsed['loras']):
return
if strength_clip is None:
strength_clip = strength_model
parsed['loras'].append({
'name': lora_name,
'strength_model': strength_model,
'strength_clip': strength_clip,
})
def parse_comfy_metadata(metadata):
"""Parse ComfyUI/SD metadata with broad heuristics"""
parsed = {
@@ -584,180 +747,171 @@ def parse_comfy_metadata(metadata):
'strength_clip': strength
})
# If parameters parsing didn't work, try ComfyUI workflow format
if not parsed['prompt'] or len(parsed['loras']) == 0:
workflow = metadata.get('workflow', {})
if isinstance(workflow, str):
workflow = json.loads(workflow)
if workflow and isinstance(workflow, dict):
nodes = workflow.get('nodes', [])
for node in nodes:
node_type = node.get('type', '')
widgets = node.get('widgets_values', [])
inputs = node.get('inputs', {})
title = node.get('title', '').lower()
# Extract prompts
if 'CLIPTextEncode' in node_type or 'Conditioning' in node_type or 'PromptText' in node_type:
text = widgets[0] if widgets and isinstance(widgets[0], str) else inputs.get('text')
if text:
# Check if it's negative prompt
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
# Extract sampler settings
elif 'KSampler' in node_type:
if not parsed['seed']:
parsed['seed'] = widgets[0] if widgets else inputs.get('seed')
if not parsed['steps']:
parsed['steps'] = widgets[1] if len(widgets) > 1 else inputs.get('steps')
if not parsed['cfg']:
parsed['cfg'] = widgets[2] if len(widgets) > 2 else inputs.get('cfg')
if not parsed['sampler']:
parsed['sampler'] = widgets[3] if len(widgets) > 3 else inputs.get('sampler_name')
if not parsed['scheduler']:
parsed['scheduler'] = widgets[4] if len(widgets) > 4 else inputs.get('scheduler')
# Extract model
elif 'CheckpointLoader' in node_type or 'CheckpointLoaderSimple' in node_type:
if not parsed['model']:
parsed['model'] = widgets[0] if widgets else inputs.get('ckpt_name')
# Extract LoRAs - COMPREHENSIVE DETECTION
elif any(kw in node_type.lower() for kw in ['lora', 'loraloader', 'lora stacker', 'lora_stack']):
try:
# LoRA Manager format - JSON objects with 'active' flag
if widgets and isinstance(widgets[0], list):
lora_list = widgets[0]
for lora_obj in lora_list:
if isinstance(lora_obj, dict):
lora_name = lora_obj.get('name')
is_active = lora_obj.get('active', True)
strength = lora_obj.get('strength', 1.0)
clip_strength = lora_obj.get('clipStrength', 1.0)
# Only add if active, has a valid name, and avoid invalid names
if (lora_name and
lora_name not in ['None', '', 'ComfyUI'] and
is_active):
# Check if already added
if not any(l['name'] == lora_name for l in parsed['loras']):
parsed['loras'].append({
'name': lora_name,
'strength_model': strength,
'strength_clip': clip_strength
})
# Standard LoRA Loader format (name, strength_model, strength_clip)
else:
lora_name = widgets[0] if widgets else inputs.get('lora_name')
# Only add valid LoRA names
if lora_name and lora_name not in ['None', '', 'ComfyUI']:
# Check if already added
if not any(l['name'] == lora_name for l in parsed['loras']):
parsed['loras'].append({
'name': lora_name,
'strength_model': widgets[1] if len(widgets) > 1 else inputs.get('strength_model', 1.0),
'strength_clip': widgets[2] if len(widgets) > 2 else inputs.get('strength_clip', 1.0)
})
except Exception as e:
print(f"Error parsing LoRAs from node {node_type}: {e}")
# Extract dimensions
elif 'EmptyLatentImage' in node_type:
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')
# 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()
widgets = node.get('widgets_values', [])
inputs = node.get('inputs', {})
title = _node_title(node).lower()
# Parse API prompt graph JSON (ComfyUI default Save Image metadata)
if isinstance(metadata.get('prompt'), dict) and (not parsed['prompt'] or not parsed['negative_prompt']):
prompt_graph = metadata['prompt']
# Extract prompts from core and custom text-encode/prompt nodes.
if any(kw in node_type_l for kw in ['cliptextencode', 'textencode', 'conditioning', '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
def _linked_text(link_ref):
if not isinstance(link_ref, (list, tuple)) or len(link_ref) == 0:
return None
node_key = str(link_ref[0])
ref_node = prompt_graph.get(node_key)
if not isinstance(ref_node, dict):
return None
ref_inputs = ref_node.get('inputs', {})
if not isinstance(ref_inputs, dict):
return None
ref_text = ref_inputs.get('text')
if isinstance(ref_text, str) and ref_text.strip():
return ref_text.strip()
return None
# Extract sampler settings.
if 'ksampler' 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)
# Preferred path: resolve positive/negative links from KSampler-like nodes
# 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']):
try:
if widgets and isinstance(widgets[0], list):
for lora_obj in widgets[0]:
if isinstance(lora_obj, dict) and 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):
text_candidates = []
api_negative_input_seen = False
for _, node in prompt_graph.items():
if not isinstance(node, dict):
continue
node_type = str(node.get('class_type') or node.get('type') or '')
if 'KSampler' not in node_type:
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
if not parsed['prompt']:
positive_text = _linked_text(node_inputs.get('positive'))
if 'ksampler' in node_type_l:
# 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.
if node_inputs.get('seed') is not None:
parsed['seed'] = node_inputs.get('seed')
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')
positive_text = _resolve_api_value(prompt_graph, node_inputs.get('positive'), ['text', 'prompt', 'positive', 'string_a', 'string'], 'text')
if positive_text:
parsed['prompt'] = positive_text
if not parsed['negative_prompt']:
negative_text = _linked_text(node_inputs.get('negative'))
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')
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
if parsed['prompt'] and parsed['negative_prompt']:
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']):
for key in ['ckpt_name', 'unet_name', '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']):
lora_name = _resolve_api_value(prompt_graph, node_inputs.get('lora_name') or node_inputs.get('name'), ['lora_name', 'name'], 'text')
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')
height_value = _resolve_api_value(prompt_graph, node_inputs.get('height'), ['height'], 'number')
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']):
text = _extract_api_node_value(prompt_graph, node, ['text', 'prompt', 'string_a', 'string'], 'text')
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
# Fallback: scan CLIP text nodes if links were unavailable
if not parsed['prompt'] or not parsed['negative_prompt']:
clip_nodes = []
for _, node in prompt_graph.items():
if not isinstance(node, dict):
continue
node_type = str(node.get('class_type') or node.get('type') or '')
if 'CLIPTextEncode' not in node_type:
continue
node_inputs = node.get('inputs', {})
if not isinstance(node_inputs, dict):
continue
text = node_inputs.get('text')
if not (isinstance(text, str) and text.strip()):
continue
title = str((node.get('_meta') or {}).get('title', '')).lower()
clip_nodes.append((text.strip(), title))
if not parsed['negative_prompt']:
for text, title in clip_nodes:
if any(word in title for word in ['negative', 'neg']):
parsed['negative_prompt'] = text
break
if not parsed['prompt'] and clip_nodes:
for text, title in clip_nodes:
if not any(word in title for word in ['negative', 'neg']):
parsed['prompt'] = text
break
# Last resort: if one side is still missing, use the remaining distinct CLIP text
if clip_nodes and (not parsed['prompt'] or not parsed['negative_prompt']):
for text, _ in clip_nodes:
if not parsed['prompt']:
parsed['prompt'] = text
continue
if not parsed['negative_prompt'] and text != parsed['prompt']:
parsed['negative_prompt'] = text
break
except Exception as e:
print(f"Error parsing metadata: {e}")
@@ -768,6 +922,13 @@ def parse_comfy_metadata(metadata):
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 parsed
def flatten_metadata(metadata):
@@ -817,19 +978,22 @@ def extract_workflow_nodes(metadata):
nodes_out = []
# UI format: {"nodes":[...]}
# UI format: {"nodes":[...], "definitions":{"subgraphs":[...]}}
if isinstance(source, dict) and isinstance(source.get('nodes'), list):
for node in source.get('nodes', []):
for node, id_prefix, subgraph_name in _iter_ui_workflow_nodes(source):
if not isinstance(node, dict):
continue
if node.get('mode', 0) != 0:
continue
node_id = node.get('id', 'N/A')
node_type = node.get('type') or node.get('class_type') or 'Unknown'
params = _normalize_node_params_from_ui_node(node)
if subgraph_name:
params.insert(0, {'name': 'subgraph', 'value': subgraph_name})
nodes_out.append({
'id': _safe_str(node_id),
'id': f"{id_prefix}{_safe_str(node_id)}",
'type': _safe_str(node_type),
'params': _normalize_node_params_from_ui_node(node)
'params': params
})
# API format: {"3":{"class_type":"KSampler","inputs":{...}}, ...}
elif isinstance(source, dict):
+212
View File
@@ -0,0 +1,212 @@
import importlib.util
import sys
import tempfile
import types
import unittest
from pathlib import Path
MODULE_PATH = Path(__file__).resolve().parents[1] / "__init__.py"
def load_image_browser_module():
"""Load the ComfyUI extension module with tiny ComfyUI/server stubs."""
routes = types.SimpleNamespace(
get=lambda _path: (lambda func: func),
post=lambda _path: (lambda func: func),
)
server_stub = types.SimpleNamespace(PromptServer=types.SimpleNamespace(instance=types.SimpleNamespace(routes=routes)))
temp_root = Path(tempfile.mkdtemp(prefix="image-browser-test-"))
folder_paths_stub = types.SimpleNamespace(
get_user_directory=lambda: str(temp_root / "user"),
user_directory=str(temp_root / "user"),
base_path=str(temp_root),
get_output_directory=lambda: str(temp_root / "output"),
get_input_directory=lambda: str(temp_root / "input"),
get_temp_directory=lambda: str(temp_root / "temp"),
)
for name in ["user", "output", "input", "temp"]:
(temp_root / name).mkdir(parents=True, exist_ok=True)
sys.modules["server"] = server_stub
sys.modules["folder_paths"] = folder_paths_stub
spec = importlib.util.spec_from_file_location("image_browser_under_test", MODULE_PATH)
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
spec.loader.exec_module(module)
return module
ib = load_image_browser_module()
class MetadataParsingTests(unittest.TestCase):
def test_default_save_image_api_prompt_follows_prompt_links_and_extracts_settings_loras_and_size(self):
metadata = {
"prompt": {
"3": {
"class_type": "KSampler",
"inputs": {
"seed": 851833015696796,
"steps": 4,
"cfg": 1.0,
"sampler_name": "euler",
"scheduler": "simple",
"positive": ["113", 0],
"negative": ["114", 0],
"latent_image": ["124", 0],
"model": ["116", 0],
},
"_meta": {"title": "KSampler"},
},
"113": {
"class_type": "TextEncodeQwenImageEditPlus",
"inputs": {"prompt": ["121", 0], "clip": ["38", 0], "vae": ["39", 0]},
"_meta": {"title": "Text Encode Positive"},
},
"114": {
"class_type": "TextEncodeQwenImageEditPlus",
"inputs": {"prompt": "low quality", "clip": ["38", 0], "vae": ["39", 0]},
"_meta": {"title": "Text Encode Negative"},
},
"121": {
"class_type": "StringConcatenate",
"inputs": {"string_a": "amateur photo", "string_b": "forest portrait", "delimiter": ", "},
"_meta": {"title": "Trigger Word + Positive Prompt"},
},
"124": {
"class_type": "EmptyLatentImage",
"inputs": {"width": ["125", 0], "height": ["125", 1], "batch_size": 1},
},
"125": {
"class_type": "ResolutionMaster",
"inputs": {"width": 1024, "height": 1024},
},
"116": {
"class_type": "NunchakuQwenImageLoraLoader",
"inputs": {"lora_name": "photo_style.safetensors", "lora_strength": 0.75, "model": ["110", 0]},
},
}
}
parsed = ib.parse_comfy_metadata(metadata)
self.assertEqual(parsed["prompt"], "amateur photo, forest portrait")
self.assertEqual(parsed["negative_prompt"], "low quality")
self.assertEqual(parsed["seed"], 851833015696796)
self.assertEqual(parsed["steps"], 4)
self.assertEqual(parsed["cfg"], 1.0)
self.assertEqual(parsed["sampler"], "euler")
self.assertEqual(parsed["scheduler"], "simple")
self.assertEqual(parsed["width"], 1024)
self.assertEqual(parsed["height"], 1024)
self.assertEqual(parsed["loras"], [{"name": "photo_style.safetensors", "strength_model": 0.75, "strength_clip": 0.75}])
def test_default_save_image_without_negative_does_not_invent_negative_prompt_from_other_positive_text_nodes(self):
metadata = {
"prompt": {
"3": {
"class_type": "KSampler",
"inputs": {
"seed": 123,
"steps": 8,
"cfg": 1.0,
"sampler_name": "euler",
"scheduler": "simple",
"positive": ["6", 0],
"latent_image": ["5", 0],
},
"_meta": {"title": "KSampler"},
},
"6": {
"class_type": "CLIPTextEncode",
"inputs": {"text": "one positive prompt", "clip": ["4", 0]},
"_meta": {"title": "Positive Prompt"},
},
"7": {
"class_type": "CLIPTextEncode",
"inputs": {"text": "another positive helper text", "clip": ["4", 0]},
"_meta": {"title": "Helper Prompt"},
},
"5": {"class_type": "EmptyLatentImage", "inputs": {"width": 768, "height": 1024}},
}
}
parsed = ib.parse_comfy_metadata(metadata)
self.assertEqual(parsed["prompt"], "one positive prompt")
self.assertIsNone(parsed["negative_prompt"])
def test_default_save_image_same_positive_and_negative_link_is_treated_as_no_negative_prompt(self):
metadata = {
"prompt": {
"3": {
"class_type": "KSampler",
"inputs": {
"seed": 123,
"steps": 8,
"cfg": 1.0,
"sampler_name": "euler",
"scheduler": "simple",
"positive": ["6", 0],
"negative": ["6", 0],
"latent_image": ["5", 0],
},
"_meta": {"title": "KSampler"},
},
"6": {
"class_type": "CLIPTextEncode",
"inputs": {"text": "one shared prompt", "clip": ["4", 0]},
"_meta": {"title": "Prompt"},
},
"5": {"class_type": "EmptyLatentImage", "inputs": {"width": 768, "height": 1024}},
}
}
parsed = ib.parse_comfy_metadata(metadata)
self.assertEqual(parsed["prompt"], "one shared prompt")
self.assertIsNone(parsed["negative_prompt"])
def test_workflow_nodes_include_nodes_inside_comfyui_subgraph_definitions(self):
metadata = {
"workflow": {
"nodes": [
{"id": 1, "type": "SaveImage", "mode": 0, "inputs": [], "widgets_values": ["img"]},
{"id": 2, "type": "MutedTopLevel", "mode": 2, "inputs": [], "widgets_values": []},
],
"definitions": {
"subgraphs": [
{
"id": "subgraph-abc",
"name": "Qwen Text-to-image (Subgraph)",
"nodes": [
{
"id": 42,
"type": "CLIPTextEncode",
"mode": 0,
"inputs": [{"name": "clip", "link": 10}],
"widgets_values": ["inside subgraph prompt"],
}
],
}
]
},
}
}
nodes = ib.extract_workflow_nodes(metadata)
node_ids = {node["id"] for node in nodes}
self.assertIn("1", node_ids)
self.assertIn("subgraph-abc:42", node_ids)
self.assertNotIn("2", node_ids)
subgraph_node = next(node for node in nodes if node["id"] == "subgraph-abc:42")
self.assertEqual(subgraph_node["type"], "CLIPTextEncode")
self.assertTrue(any(param["name"] == "subgraph" and param["value"] == "Qwen Text-to-image (Subgraph)" for param in subgraph_node["params"]))
if __name__ == "__main__":
unittest.main()