ComfyUI-PNG-Metadata / png_metadata_decoder.py
Kisaraji's picture
Upload 5 files
a737419 verified
import json
import re
from PIL import Image
class TZ_DecodePNGMetadata:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"image_path": ("STRING", {"forceInput": True}),
}
}
RETURN_TYPES = (
"STRING", "STRING", "STRING",
"INT", "INT", "FLOAT", "INT", "INT",
"STRING"
)
RETURN_NAMES = (
"positive_prompt",
"negative_prompt",
"raw_metadata",
"seed_int",
"steps_int",
"cfg_float",
"width_int",
"height_int",
"summary_text"
)
FUNCTION = "decode_metadata"
CATEGORY = "Herve/metadata"
TEXT_KEYS_POSITIVE = {
"text", "prompt", "positive", "positive_prompt", "string",
"caption", "content", "prompt_text"
}
TEXT_KEYS_NEGATIVE = {
"negative", "negative_prompt", "neg", "negative_text"
}
LORA_HINT_KEYS = {
"lora_name", "loras", "lora", "lora_stack", "stack",
"loralist", "lora_model", "adapter_name"
}
MODEL_HINT_KEYS = {
"ckpt_name", "model", "model_name", "checkpoint", "unet_name"
}
SIZE_KEYS_W = {"width", "latent_width", "target_width"}
SIZE_KEYS_H = {"height", "latent_height", "target_height"}
def safe_str(self, value, default="Unknown"):
if value is None:
return default
text = str(value).strip()
return text if text else default
def try_json(self, value):
if isinstance(value, (dict, list)):
return value
if not isinstance(value, str):
return value
s = value.strip()
if s.startswith("{") or s.startswith("["):
try:
return json.loads(s)
except Exception:
return value
return value
def to_int(self, value, default=0):
try:
if isinstance(value, bool):
return default
if isinstance(value, int):
return value
if isinstance(value, float):
return int(value)
s = str(value).strip()
m = re.search(r"-?\d+", s)
if m:
return int(m.group(0))
except Exception:
pass
return default
def to_float(self, value, default=0.0):
try:
if isinstance(value, bool):
return default
if isinstance(value, (int, float)):
return float(value)
s = str(value).strip().replace(",", ".")
m = re.search(r"-?\d+(?:\.\d+)?", s)
if m:
return float(m.group(0))
except Exception:
pass
return default
def looks_like_prompt_text(self, text):
if not isinstance(text, str):
return False
s = text.strip()
if len(s) < 20:
return False
if s.startswith("{") or s.startswith("["):
return False
return True
def extract_loras_from_text(self, text):
found = set()
if not isinstance(text, str):
return found
for name in re.findall(r"<lora:([^:>]+)", text, flags=re.IGNORECASE):
clean = str(name).strip()
if clean:
found.add(clean)
for name in re.findall(r"\b([A-Za-z0-9_\-./]+\.safetensors)\b", text, flags=re.IGNORECASE):
lowered = name.lower()
if "lora" in lowered or "lyco" in lowered or "adapter" in lowered:
found.add(name)
return found
def parse_size_string(self, value):
if not isinstance(value, str):
return 0, 0
m = re.search(r"(\d+)\s*x\s*(\d+)", value)
if m:
return int(m.group(1)), int(m.group(2))
return 0, 0
def resolve_linked_value(self, node_map, link_value, visited=None):
if visited is None:
visited = set()
if not isinstance(link_value, list) or len(link_value) < 1:
return None
node_id = str(link_value[0])
if node_id in visited:
return None
visited.add(node_id)
node = node_map.get(node_id, {})
inputs = node.get("inputs", {}) or {}
for _, value in inputs.items():
if isinstance(value, (int, float)):
return value
if isinstance(value, str):
num = self.to_int(value, None)
if num is not None:
return num
if isinstance(value, list):
resolved = self.resolve_linked_value(node_map, value, visited)
if resolved is not None:
return resolved
widgets = node.get("widgets_values", [])
if isinstance(widgets, list):
for v in widgets:
if isinstance(v, (int, float)):
return v
if isinstance(v, str):
num = self.to_int(v, None)
if num is not None:
return num
return None
def resolve_linked_text(self, node_map, link_value, visited=None):
if visited is None:
visited = set()
if not isinstance(link_value, list) or len(link_value) < 1:
return ""
node_id = str(link_value[0])
if node_id in visited:
return ""
visited.add(node_id)
node = node_map.get(node_id, {})
inputs = node.get("inputs", {}) or {}
class_type = str(node.get("class_type", "")).lower()
for key, value in inputs.items():
key_l = str(key).lower()
if key_l in self.TEXT_KEYS_POSITIVE or key_l in self.TEXT_KEYS_NEGATIVE:
if isinstance(value, str) and self.looks_like_prompt_text(value):
return value.strip()
if isinstance(value, list):
resolved = self.resolve_linked_text(node_map, value, visited)
if resolved:
return resolved
if "textencode" in class_type or "prompt" in class_type or "text" in class_type:
for value in inputs.values():
if isinstance(value, str) and self.looks_like_prompt_text(value):
return value.strip()
if isinstance(value, list):
resolved = self.resolve_linked_text(node_map, value, visited)
if resolved:
return resolved
return ""
def parse_a1111_parameters(self, text):
result = {
"positive_prompt": "",
"negative_prompt": "",
"model": "Unknown",
"software": "AUTOMATIC1111",
"sampler": "Unknown",
"steps": "Unknown",
"cfg_scale": "Unknown",
"seed": "Unknown",
"image_size": "Unknown",
"loras_used": "None",
"width_int": 0,
"height_int": 0,
"seed_int": 0,
"steps_int": 0,
"cfg_float": 0.0,
}
if not text or not isinstance(text, str):
return result
lines = text.splitlines()
if not lines:
return result
params_line_index = None
for i, line in enumerate(lines):
if re.search(r"Steps:\s*\d+", line, flags=re.IGNORECASE):
params_line_index = i
break
if params_line_index is None:
result["positive_prompt"] = text.strip()
loras = self.extract_loras_from_text(text)
if loras:
result["loras_used"] = ", ".join(sorted(loras))
return result
top_block = "\n".join(lines[:params_line_index]).strip()
params_block = "\n".join(lines[params_line_index:]).strip()
if "Negative prompt:" in top_block:
p, n = top_block.split("Negative prompt:", 1)
result["positive_prompt"] = p.strip()
result["negative_prompt"] = n.strip()
else:
result["positive_prompt"] = top_block
patterns = {
"steps": r"Steps:\s*([^,\n]+)",
"sampler": r"Sampler:\s*([^,\n]+)",
"cfg_scale": r"CFG scale:\s*([^,\n]+)",
"seed": r"Seed:\s*([^,\n]+)",
"image_size": r"Size:\s*([^,\n]+)",
"model": r"Model:\s*([^,\n]+)",
}
for key, pattern in patterns.items():
m = re.search(pattern, params_block, flags=re.IGNORECASE)
if m:
result[key] = m.group(1).strip()
w, h = self.parse_size_string(result["image_size"])
result["width_int"] = w
result["height_int"] = h
result["seed_int"] = self.to_int(result["seed"], 0)
result["steps_int"] = self.to_int(result["steps"], 0)
result["cfg_float"] = self.to_float(result["cfg_scale"], 0.0)
loras = set()
loras |= self.extract_loras_from_text(text)
if loras:
result["loras_used"] = ", ".join(sorted(loras))
return result
def find_comfy_data(self, data):
result = {
"positive_prompt": "",
"negative_prompt": "",
"model": "Unknown",
"software": "ComfyUI",
"sampler": "Unknown",
"steps": "Unknown",
"cfg_scale": "Unknown",
"seed": "Unknown",
"image_size": "Unknown",
"loras_used": "None",
"width_int": 0,
"height_int": 0,
"seed_int": 0,
"steps_int": 0,
"cfg_float": 0.0,
}
if not isinstance(data, dict):
return result
node_map = data
found_loras = set()
positive_candidates = []
negative_candidates = []
for _, node in node_map.items():
class_type = str(node.get("class_type", ""))
class_lower = class_type.lower()
inputs = node.get("inputs", {}) or {}
if class_type in ["KSampler", "KSamplerAdvanced"] or "ksampler" in class_lower:
if "seed" in inputs:
result["seed"] = str(inputs.get("seed"))
result["seed_int"] = self.to_int(inputs.get("seed"), result["seed_int"])
if "steps" in inputs:
result["steps"] = str(inputs.get("steps"))
result["steps_int"] = self.to_int(inputs.get("steps"), result["steps_int"])
if "cfg" in inputs:
result["cfg_scale"] = str(inputs.get("cfg"))
result["cfg_float"] = self.to_float(inputs.get("cfg"), result["cfg_float"])
sampler_name = str(inputs.get("sampler_name", "")).strip()
scheduler = str(inputs.get("scheduler", "")).strip()
if sampler_name and scheduler:
result["sampler"] = f"{sampler_name} / {scheduler}"
elif sampler_name:
result["sampler"] = sampler_name
elif scheduler:
result["sampler"] = scheduler
if "positive" in inputs:
txt = self.resolve_linked_text(node_map, inputs["positive"])
if txt:
positive_candidates.append(txt)
if "negative" in inputs:
txt = self.resolve_linked_text(node_map, inputs["negative"])
if txt:
negative_candidates.append(txt)
for mk in self.MODEL_HINT_KEYS:
if mk in inputs:
val = inputs.get(mk)
if isinstance(val, str) and val.strip():
result["model"] = val.strip()
break
width_val = None
height_val = None
for wk in self.SIZE_KEYS_W:
if wk in inputs:
width_val = inputs[wk]
break
for hk in self.SIZE_KEYS_H:
if hk in inputs:
height_val = inputs[hk]
break
if width_val is not None and height_val is not None:
if isinstance(width_val, list):
width_val = self.resolve_linked_value(node_map, width_val)
if isinstance(height_val, list):
height_val = self.resolve_linked_value(node_map, height_val)
w = self.to_int(width_val, 0)
h = self.to_int(height_val, 0)
if w > 0 and h > 0:
result["width_int"] = w
result["height_int"] = h
result["image_size"] = f"{w}x{h}"
for key, value in inputs.items():
key_l = str(key).lower()
if key_l in self.TEXT_KEYS_POSITIVE and isinstance(value, str) and self.looks_like_prompt_text(value):
positive_candidates.append(value.strip())
if key_l in self.TEXT_KEYS_NEGATIVE and isinstance(value, str) and self.looks_like_prompt_text(value):
negative_candidates.append(value.strip())
if isinstance(value, str):
found_loras |= self.extract_loras_from_text(value)
if key_l in self.LORA_HINT_KEYS:
if isinstance(value, str) and value.strip():
found_loras.add(value.strip())
elif isinstance(value, list):
resolved = self.resolve_linked_text(node_map, value)
if resolved:
found_loras |= self.extract_loras_from_text(resolved)
if "lora" in class_lower or "lyco" in class_lower or "adapter" in class_lower:
for key, value in inputs.items():
if isinstance(value, str) and value.strip():
key_l = str(key).lower()
if "name" in key_l or "lora" in key_l or value.lower().endswith(".safetensors"):
found_loras.add(value.strip())
if positive_candidates:
result["positive_prompt"] = max(positive_candidates, key=len)
if negative_candidates:
result["negative_prompt"] = max(negative_candidates, key=len)
if result["width_int"] > 0 and result["height_int"] > 0:
result["image_size"] = f"{result['width_int']}x{result['height_int']}"
if found_loras:
clean = sorted(x for x in found_loras if x and x.lower() != "none")
if clean:
result["loras_used"] = ", ".join(clean)
result["positive_prompt"] = self.safe_str(result["positive_prompt"], "")
result["negative_prompt"] = self.safe_str(result["negative_prompt"], "")
return result
def summarize(self, data):
return (
f"Positive prompt:\n{data['positive_prompt']}\n\n"
f"Negative prompt:\n{data['negative_prompt']}\n\n"
f"Model: {data['model']}\n"
f"Software: {data['software']}\n"
f"Sampler: {data['sampler']}\n"
f"Steps: {data['steps']}\n"
f"CFG Scale: {data['cfg_scale']}\n"
f"Seed: {data['seed']}\n"
f"Image size: {data['image_size']}\n"
f"Loras used: {data['loras_used']}"
)
def decode_metadata(self, image_path):
try:
img = Image.open(image_path)
info = getattr(img, "info", {}) or {}
raw_metadata = json.dumps(info, ensure_ascii=False, indent=2)
if "parameters" in info and isinstance(info["parameters"], str):
parsed = self.parse_a1111_parameters(info["parameters"])
else:
comfy_source = None
if "prompt" in info:
comfy_source = self.try_json(info["prompt"])
elif "workflow" in info:
comfy_source = self.try_json(info["workflow"])
parsed = self.find_comfy_data(comfy_source)
summary = self.summarize(parsed)
return (
parsed["positive_prompt"],
parsed["negative_prompt"],
raw_metadata,
parsed["seed_int"],
parsed["steps_int"],
parsed["cfg_float"],
parsed["width_int"],
parsed["height_int"],
summary
)
except Exception as e:
err = f"Error reading metadata: {e}"
return ("", "", err, 0, 0, 0.0, 0, 0, err)