| 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) |
|
|