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"]+)", 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)