#!/usr/bin/env python3 """Extract model and LoRA metadata from ComfyUI-generated images. Outputs a JSON mapping of path (relative to target) → {"model": "...", "loras": ["...", ...], "dest": "{model_dir}/{lora_dir}"} Only files with parseable ComfyUI 'prompt' metadata are included, so presence in the output doubles as "is an AI-generated image" for organize-images. 'dest' is the canonical folder path (sans leading "AI/"): model/LoRA names are path-stripped and sanitized (spaces/specials → _), LoRAs joined with '+', missing model → "unknown", no LoRAs → "no-lora". Usage: extract-ai-meta [path] [-r|--recursive] """ import json import os import re import sys try: from PIL import Image except ImportError: print("Error: Pillow (PIL) required. Install with: pip install Pillow", file=sys.stderr) sys.exit(1) IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".webp"} CHECKPOINT_CLASS_TYPES = { "CheckpointLoaderSimple", "Checkpoint Loader with Name (Image Saver)", "CheckpointLoader", } MODEL_CLASS_TYPES = { "UNETLoader", } LORA_CLASS_TYPES = { "LoraLoader", "Power Lora Loader (rgthree)", } # Filesystem-safe cap for the joined LoRA combo directory name MAX_LORA_DIR_LEN = 200 def _strip_model_name(raw): """Strip path prefix and .safetensors extension from a model/LoRA name.""" name = raw.strip() # Handle backslash/forward-slash paths: "Pony\model.safetensors" or "Pony/model.safetensors" name = name.replace("\\", "/") name = os.path.basename(name) if name.endswith(".safetensors"): name = name[:-len(".safetensors")] return name def sanitize_dir_name(name): """Canonical directory-name sanitizer (spaces/specials → _).""" name = name.strip().replace(" ", "_") name = re.sub(r"[^a-zA-Z0-9_.-]", "_", name) name = re.sub(r"_+", "_", name).strip("_") return name def is_lora_connected(node_id, data): """Check if a LoRA node's output is consumed by any downstream node.""" for nid, node in data.items(): if nid == node_id: continue for val in node.get("inputs", {}).values(): if isinstance(val, list) and len(val) == 2 and str(val[0]) == str(node_id): return True return False def extract_ai_meta(filepath): """Return (model, loras, has_prompt). has_prompt is True whenever the file carries parseable ComfyUI 'prompt' metadata, even if no model/LoRA nodes were found in it. """ try: img = Image.open(filepath) except Exception: return None, [], False if "prompt" not in img.info: return None, [], False try: data = json.loads(img.info["prompt"]) except (json.JSONDecodeError, TypeError): return None, [], False model = None loras = [] for nid, node in data.items(): cls = node.get("class_type", "") inputs = node.get("inputs", {}) if model is None: if cls in CHECKPOINT_CLASS_TYPES and inputs.get("ckpt_name"): model = _strip_model_name(inputs["ckpt_name"]) elif cls in MODEL_CLASS_TYPES and inputs.get("unet_name"): model = _strip_model_name(inputs["unet_name"]) if cls in LORA_CLASS_TYPES and inputs.get("lora_name"): # Only include LoRAs whose output is actually consumed if is_lora_connected(nid, data): loras.append(_strip_model_name(inputs["lora_name"])) else: # Some custom nodes use fields ending in 'lora'/'lora_name' for field, val in inputs.items(): if isinstance(val, str) and val.endswith(".safetensors") and "lora" in field.lower(): name = _strip_model_name(val) if name: loras.append(name) loras = sorted(set(loras)) return model, loras, True def dest_for(model, loras): """Canonical relative destination dir (sans 'AI/' prefix).""" model_dir = sanitize_dir_name(model) if model else "unknown" if loras: lora_dir = "+".join(sanitize_dir_name(l) for l in loras if l) if len(lora_dir) > MAX_LORA_DIR_LEN: lora_dir = lora_dir[:MAX_LORA_DIR_LEN].rstrip("+_") if not lora_dir: lora_dir = "no-lora" else: lora_dir = "no-lora" return f"{model_dir}/{lora_dir}" def iter_images(target, recursive): if recursive: for dirpath, _dirnames, filenames in os.walk(target): for fn in sorted(filenames): if os.path.splitext(fn)[1].lower() in IMAGE_EXTS: yield os.path.join(dirpath, fn) else: for entry in sorted(os.listdir(target)): fpath = os.path.join(target, entry) if os.path.isfile(fpath) and os.path.splitext(entry)[1].lower() in IMAGE_EXTS: yield fpath def main(): args = [a for a in sys.argv[1:] if not a.startswith("-")] recursive = any(a in ("-r", "--recursive") for a in sys.argv[1:]) target = args[0] if args else os.path.expanduser("~/Pictures") target = os.path.abspath(target) if not os.path.isdir(target): print(f"Error: {target} is not a directory", file=sys.stderr) sys.exit(1) meta = {} scanned = 0 for fpath in iter_images(target, recursive): scanned += 1 if scanned % 200 == 0: print(f"\r …scanned {scanned} images ({len(meta)} with AI metadata)", end="", file=sys.stderr, flush=True) model, loras, has_prompt = extract_ai_meta(fpath) if not has_prompt: continue rel = os.path.relpath(fpath, target) meta[rel] = { "model": model, "loras": loras, "dest": dest_for(model, loras), } if scanned: print(f"\r Scanned {scanned} images, {len(meta)} with AI metadata. ", file=sys.stderr, flush=True) print(json.dumps(meta, indent=2)) if __name__ == "__main__": main()