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:
@@ -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
@@ -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):
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user