tlam/metadata
3
1import gradio as gr2from PIL import Image3from PIL.ExifTags import TAGS4import re5import json6import tempfile7import os8import math9 10 11def get_aspect_ratio(w, h):12 """Return the closest standard aspect ratio string for given dimensions."""13 if w == 0 or h == 0:14 return "N/A"15 ratio = w / h16 tolerance = 0.0217 ratios = {18 "1:1": 1.0,19 "5:4": 5 / 4,20 "4:3": 4 / 3,21 "3:2": 3 / 2,22 "16:9": 16 / 9,23 "16:10": 16 / 10,24 "21:9": 21 / 9,25 "2:3": 2 / 3,26 "3:4": 3 / 4,27 "4:5": 4 / 5,28 "9:16": 9 / 16,29 }30 for label, val in ratios.items():31 if abs(ratio - val) < tolerance:32 return label33 gcd = math.gcd(w, h)34 return f"{w // gcd}:{h // gcd}"35 36 37def get_image_metadata(img):38 """Extract metadata based on image format."""39 metadata = {}40 if img.format == "PNG":41 metadata = img.info42 elif img.format in ["JPEG", "TIFF", "WEBP"]:43 exif_data = img._getexif()44 if exif_data:45 for tag, value in exif_data.items():46 tag_name = TAGS.get(tag, tag)47 metadata[tag_name] = value48 return metadata49 50 51def parse_comfy_prompt(prompt_data):52 """Parse ComfyUI prompt JSON using graph tracing for correct pos/neg identification."""53 positive_prompt = "N/A"54 negative_prompt = "N/A"55 seed = "N/A"56 57 try:58 if isinstance(prompt_data, str):59 prompt_data = json.loads(prompt_data)60 except (json.JSONDecodeError, TypeError):61 return positive_prompt, negative_prompt, seed, []62 63 if not isinstance(prompt_data, dict):64 return positive_prompt, negative_prompt, seed, []65 66 clip_text_nodes = {}67 lora_names = []68 69 for node_id, node in prompt_data.items():70 if not isinstance(node, dict):71 continue72 class_type = node.get("class_type", "")73 inputs = node.get("inputs", {})74 widgets = node.get("widgets_values", [])75 76 if class_type in ("CLIPTextEncode", "CLIPTextEncodeSDXL"):77 text = inputs.get("text", "")78 if isinstance(text, list):79 text = ""80 if not text or not text.strip():81 continue82 clip_text_nodes[node_id] = {83 "text": text.strip(),84 "links": node.get("inputs", []),85 }86 87 if "LoraLoader" in class_type or "LoraLoaderModelOnly" in class_type:88 lora_name = inputs.get("lora_name", inputs.get("lora", ""))89 if isinstance(lora_name, str) and lora_name.strip():90 lora_names.append(lora_name.strip())91 for wv in widgets:92 if isinstance(wv, str) and wv.endswith(93 (".safetensors", ".ckpt", ".pt")94 ):95 if wv not in lora_names:96 lora_names.append(wv)97 98 if class_type in (99 "KSampler",100 "KSamplerAdvanced",101 "SamplerCustom",102 "SamplerCustomAdvanced",103 ):104 if "seed" in inputs:105 seed = str(inputs["seed"])106 if "noise_seed" in inputs:107 seed = str(inputs["noise_seed"])108 109 positive_ids = set()110 negative_ids = set()111 112 for node_id, node in prompt_data.items():113 if not isinstance(node, dict):114 continue115 class_type = node.get("class_type", "")116 inputs = node.get("inputs", {})117 118 is_sampler = class_type in (119 "KSampler",120 "KSamplerAdvanced",121 "SamplerCustom",122 "SamplerCustomAdvanced",123 )124 if is_sampler:125 for input_name in ("positive", "latent_image"):126 link = inputs.get(input_name)127 if isinstance(link, list) and len(link) >= 1:128 positive_ids.add(str(link[0]))129 for input_name in ("negative",):130 link = inputs.get(input_name)131 if isinstance(link, list) and len(link) >= 1:132 negative_ids.add(str(link[0]))133 134 if class_type in ("SamplerCustom", "SamplerCustomAdvanced"):135 for input_name in ("guider", "sampler", "sigmas"):136 link = inputs.get(input_name)137 if isinstance(link, list) and len(link) >= 1:138 pass139 140 def trace_positive(node_id, visited=None):141 if visited is None:142 visited = set()143 if node_id in visited:144 return []145 visited.add(node_id)146 node = prompt_data.get(node_id, {})147 if not isinstance(node, dict):148 return []149 class_type = node.get("class_type", "")150 inputs = node.get("inputs", {})151 texts = []152 if class_type in ("CLIPTextEncode", "CLIPTextEncodeSDXL"):153 text = inputs.get("text", "")154 if isinstance(text, list):155 text = ""156 if text and text.strip():157 texts.append(text.strip())158 for input_name, link in inputs.items():159 if isinstance(link, list) and len(link) >= 1:160 texts.extend(trace_positive(str(link[0]), visited))161 return texts162 163 def trace_negative(node_id, visited=None):164 if visited is None:165 visited = set()166 if node_id in visited:167 return []168 visited.add(node_id)169 node = prompt_data.get(node_id, {})170 if not isinstance(node, dict):171 return []172 class_type = node.get("class_type", "")173 inputs = node.get("inputs", {})174 texts = []175 if class_type in ("CLIPTextEncode", "CLIPTextEncodeSDXL"):176 text = inputs.get("text", "")177 if isinstance(text, list):178 text = ""179 if text and text.strip():180 texts.append(text.strip())181 for input_name, link in inputs.items():182 if isinstance(link, list) and len(link) >= 1:183 texts.extend(trace_negative(str(link[0]), visited))184 return texts185 186 found_pos = False187 found_neg = False188 for nid in sorted(positive_ids):189 if nid in clip_text_nodes:190 positive_prompt = clip_text_nodes[nid]["text"]191 found_pos = True192 break193 if not found_pos:194 for nid in sorted(positive_ids):195 texts = trace_positive(nid)196 if texts:197 positive_prompt = texts[0]198 found_pos = True199 break200 201 for nid in sorted(negative_ids):202 if nid in clip_text_nodes:203 negative_prompt = clip_text_nodes[nid]["text"]204 found_neg = True205 break206 if not found_neg:207 for nid in sorted(negative_ids):208 texts = trace_negative(nid)209 if texts:210 negative_prompt = texts[0]211 found_neg = True212 break213 214 if not found_pos and not found_neg:215 texts = [n["text"] for n in clip_text_nodes.values()]216 if len(texts) >= 1:217 positive_prompt = texts[0]218 if len(texts) >= 2:219 negative_prompt = texts[1]220 221 return positive_prompt, negative_prompt, seed, lora_names222 223 224def extract_metadata(image_file):225 """Extract and parse metadata from the uploaded image."""226 try:227 img = Image.open(image_file)228 except Exception:229 return ("Error: Unable to open image.", *(["N/A"] * 7))230 231 metadata = get_image_metadata(img)232 width, height = img.size233 aspect_ratio = get_aspect_ratio(width, height)234 dimensions = f"{width} × {height} ({aspect_ratio})"235 236 metadata_str = "\n".join([f"{key}: {value}" for key, value in metadata.items()])237 238 prompt = "N/A"239 negative_prompt = "N/A"240 seed_number = "N/A"241 comfy_workflow = "N/A"242 lora_names = []243 244 comfy_prompt_data = metadata.get("prompt", None)245 if comfy_prompt_data is not None:246 pos, neg, s, loras = parse_comfy_prompt(comfy_prompt_data)247 if pos != "N/A":248 prompt = pos249 if neg != "N/A":250 negative_prompt = neg251 if s != "N/A":252 seed_number = s253 lora_names = loras254 255 elif "parameters" in metadata:256 params = metadata["parameters"]257 prompt_match = re.search(258 r"(.*?)Negative prompt:", params, re.DOTALL | re.IGNORECASE259 )260 if prompt_match:261 prompt = prompt_match.group(1).strip()262 else:263 prompt_match = re.search(264 r"(.*?)(Steps:|$)", params, re.DOTALL | re.IGNORECASE265 )266 if prompt_match:267 prompt = prompt_match.group(1).strip()268 269 neg_match = re.search(270 r"Negative prompt:(.*?)(Steps:|$)", params, re.DOTALL | re.IGNORECASE271 )272 if neg_match:273 negative_prompt = neg_match.group(1).strip()274 275 seed_match = re.search(r"Seed:\s*(\d+)", params, re.IGNORECASE)276 if seed_match:277 seed_number = seed_match.group(1).strip()278 279 lora_pattern = re.compile(r"<lora:([^:>]+)", re.IGNORECASE)280 lora_names = lora_pattern.findall(params)281 282 workflow_data = metadata.get("workflow", None)283 if workflow_data is not None:284 if isinstance(workflow_data, str):285 try:286 json.loads(workflow_data)287 comfy_workflow = workflow_data288 except json.JSONDecodeError:289 comfy_workflow = workflow_data290 elif isinstance(workflow_data, dict):291 comfy_workflow = json.dumps(workflow_data, indent=2)292 else:293 comfy_workflow = str(workflow_data)294 295 return (296 dimensions,297 prompt,298 negative_prompt,299 seed_number,300 metadata_str,301 comfy_workflow,302 lora_names,303 "N/A",304 )305 306 307def export_workflow(workflow_text):308 """Convert the workflow text into a downloadable JSON file."""309 if workflow_text == "N/A" or not workflow_text.strip():310 return None, "No workflow data to export."311 312 workflow_data = {"comfy_workflow": workflow_text}313 314 try:315 with tempfile.NamedTemporaryFile(316 mode="w", delete=False, suffix=".json"317 ) as tmp_file:318 json.dump(workflow_data, tmp_file, indent=4)319 tmp_file_path = tmp_file.name320 except Exception:321 return None, "Failed to create workflow JSON file."322 323 if os.path.exists(tmp_file_path):324 return tmp_file_path, "Workflow exported successfully."325 else:326 return None, "Failed to export workflow."327 328 329def strip_metadata(image_file):330 """Strip all metadata from an image and return a clean file."""331 if image_file is None:332 return None, "No image provided."333 334 try:335 img = Image.open(image_file)336 except Exception:337 return None, "Error: Unable to open image."338 339 clean_img = Image.new(img.mode, img.size)340 clean_img.putdata(list(img.getdata()))341 342 try:343 with tempfile.NamedTemporaryFile(344 mode="wb", delete=False, suffix=".png"345 ) as tmp_file:346 clean_img.save(tmp_file, format="PNG")347 tmp_file_path = tmp_file.name348 except Exception:349 return None, "Error saving stripped image."350 351 return tmp_file_path, "Metadata stripped successfully."352 353 354def main():355 with gr.Blocks() as iface:356 gr.Markdown("<h1>Comfy / A1111 Metadata Reader</h1>")357 gr.Markdown(358 "<p>Upload an image (PNG, JPEG, WebP) to extract its metadata and parse it for prompts.</p>"359 )360 with gr.Row():361 with gr.Column(scale=1):362 image_input = gr.Image(label="Drop Image Here", type="filepath")363 364 with gr.Column(scale=2):365 dimensions_output = gr.Textbox(366 label="Dimensions", lines=1, interactive=False367 )368 prompt_output = gr.Textbox(369 label="Prompt", lines=4, show_copy_button=True, interactive=False370 )371 negative_prompt_output = gr.Textbox(372 label="Negative Prompt",373 lines=4,374 show_copy_button=True,375 interactive=False,376 )377 seed_output = gr.Textbox(378 label="Seed Number",379 lines=1,380 show_copy_button=True,381 interactive=False,382 )383 lora_output = gr.Textbox(384 label="LoRA(s)", lines=2, show_copy_button=True, interactive=False385 )386 original_metadata_output = gr.Textbox(387 label="Original Metadata", lines=15, interactive=False388 )389 390 with gr.Column(scale=2):391 comfy_workflow_output = gr.Textbox(392 label="Comfy Workflow", lines=20, value="N/A", interactive=False393 )394 with gr.Row():395 export_button = gr.Button("Export Workflow as JSON")396 strip_button = gr.Button("Strip Metadata", variant="stop")397 workflow_file = gr.File(label="Download Workflow JSON", visible=False)398 export_message = gr.Textbox(399 label="Export Status", lines=1, interactive=False400 )401 strip_file = gr.File(label="Download Stripped Image", visible=False)402 strip_message = gr.Textbox(403 label="Strip Status", lines=1, interactive=False404 )405 406 image_input.change(407 fn=extract_metadata,408 inputs=image_input,409 outputs=[410 dimensions_output,411 prompt_output,412 negative_prompt_output,413 seed_output,414 original_metadata_output,415 comfy_workflow_output,416 lora_output,417 strip_message,418 ],419 )420 421 export_button.click(422 fn=export_workflow,423 inputs=comfy_workflow_output,424 outputs=[workflow_file, export_message],425 queue=False,426 )427 428 strip_button.click(429 fn=strip_metadata,430 inputs=image_input,431 outputs=[strip_file, strip_message],432 queue=False,433 )434 435 iface.launch()436 437 438if __name__ == "__main__":439 main()440 